Skip to content

Commit 81b5752

Browse files
committed
feat: finalize mvp dev-widget with adapters, monitor domain, and docs
1 parent c9d223b commit 81b5752

58 files changed

Lines changed: 5517 additions & 213 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ jobs:
106106
- name: Build
107107
run: bun run build
108108

109+
- name: Validate publish artifact
110+
run: |
111+
npm pack --dry-run 2>&1 | tee pack-output.txt
112+
grep -q "dist/index.mjs" pack-output.txt
113+
grep -q "dist/index.d.ts" pack-output.txt
114+
grep -q "README.md" pack-output.txt
115+
109116
- name: Upload build artifacts
110117
uses: actions/upload-artifact@v4
111118
with:

README.md

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# @remcostoeten/dev-widget
22

3+
[![npm version](https://img.shields.io/npm/v/@remcostoeten/dev-widget)](https://www.npmjs.com/package/@remcostoeten/dev-widget)
4+
[![CI](https://github.com/remcostoeten/dev-widget/actions/workflows/ci.yml/badge.svg)](https://github.com/remcostoeten/dev-widget/actions/workflows/ci.yml)
5+
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6+
37
Minimal, type-safe dev widget for React apps.
48

59
## Installation
@@ -30,14 +34,73 @@ export function App(): React.ReactElement {
3034
}
3135
```
3236

37+
## Features
38+
39+
- Auth domain
40+
- Routes domain
41+
- Links domain
42+
- Performance domain
43+
- System domain
44+
- Monitor domain
45+
46+
## Feature checklist
47+
48+
- [x] React widget shell with tab categories
49+
- [x] Domain lifecycle (setup/start/stop) via runtime registry
50+
- [x] Plugin API with typed event subscription
51+
- [x] Event bus for cross-domain communication
52+
- [x] Next.js log ingestion route handler
53+
- [x] In-memory storage adapter
54+
- [x] Tauri storage adapter export
55+
- [x] Route discovery adapter for Next.js and React projects
56+
- [x] Right rail with diagnostics render panel
57+
- [x] Keyboard shortcuts (toggle + Escape)
58+
- [x] Accessibility: focus-visible, reduced-motion handling, live region announcements
59+
- [x] Internal subpath export (`@remcostoeten/dev-widget/internal`)
60+
- [x] Source maps and declaration output in package build
61+
- [x] CI publish artifact validation step
62+
- [x] Demo app build pipeline
63+
64+
## Needs additional work
65+
66+
- AI providers: `openai` and `local` modes are declared but not implemented yet; only `rules` and `custom` are active (`src/domains/performance/aiProviderFactory.ts`).
67+
- Runtime prop updates: `DevWidget` memoizes runtime by `shouldRender` only, so runtime does not re-bootstrap when non-visibility props change after mount (`src/devWidget.tsx`).
68+
- Storage wiring: `providers.storage` is part of public config but not wired into runtime/domain persistence flow yet (`src/types/public.ts` and `src/core/bootstrap.ts`).
69+
- Endpoint validation: Next log handler accepts event payloads without schema validation; malformed event shapes can be persisted (`src/adapters/next/routeHandler.ts`).
70+
- CI parity: local checks pass, but final release confidence still depends on a pushed branch and green remote GitHub Actions run.
71+
72+
## Plugin API
73+
74+
```ts
75+
import { createRuntime } from '@remcostoeten/dev-widget'
76+
77+
const runtime = createRuntime({ mode: 'always' })
78+
79+
await runtime.registerPlugin({
80+
id: 'my-plugin',
81+
version: '1.0.0',
82+
register(ctx) {
83+
ctx.onEvent('performance/drop-detected', function onDrop(payload) {
84+
console.log('FPS drop:', payload.fps)
85+
})
86+
}
87+
})
88+
```
89+
3390
## Subpath exports
3491

3592
```ts
3693
import { DevWidget } from '@remcostoeten/dev-widget/react'
3794
import { createLogHandler } from '@remcostoeten/dev-widget/next'
3895
import { createTauriStorage } from '@remcostoeten/dev-widget/tauri'
96+
import type { WidgetDomain } from '@remcostoeten/dev-widget/internal'
3997
```
4098

99+
## Accessibility
100+
101+
The widget includes keyboard shortcuts, focus-visible styling, live announcements, and reduced-motion support.
102+
See `specs/10-dev-widget-accessibility-keyboard-spec.md` for the detailed behavior contract.
103+
41104
## Core options
42105

43106
- `enabled?: boolean`
@@ -50,12 +113,18 @@ import { createTauriStorage } from '@remcostoeten/dev-widget/tauri'
50113
- `accessibility?: { reducedMotion?: 'system' | 'always' | 'never'; announceUpdates?: boolean }`
51114
- `rightRail?: { enabled?: boolean; defaultPanel?: string | null; collapsible?: boolean; panels?: PanelConfig[] }`
52115

53-
## Types
116+
## Changelog
54117

55-
Root export includes the full public API and all public types.
118+
- [CHANGELOG.md](CHANGELOG.md)
56119

57-
```ts
58-
import type { WidgetProps, FeatureConfig, PerfEvent } from '@remcostoeten/dev-widget'
120+
## Contributing
121+
122+
```bash
123+
bun run lint
124+
bun run format:check
125+
bun run type-check
126+
bun test
127+
bun run build
59128
```
60129

61130
## License

__test__/adapters.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { createLogHandler } from '../src/adapters/next/routeHandler'
3+
4+
describe('createLogHandler', () => {
5+
test('POST returns success with event count', async () => {
6+
const handler = createLogHandler()
7+
const req = new Request('http://localhost/api/dev-widget/logs', {
8+
method: 'POST',
9+
body: JSON.stringify({ events: [{ id: '1' }, { id: '2' }] }),
10+
headers: { 'Content-Type': 'application/json' }
11+
})
12+
13+
const res = await handler.POST(req)
14+
const body = (await res.json()) as { success: boolean; count: number }
15+
expect(body.success).toBe(true)
16+
expect(body.count).toBe(2)
17+
})
18+
19+
test('POST handles invalid JSON', async () => {
20+
const handler = createLogHandler()
21+
const req = new Request('http://localhost/api/dev-widget/logs', {
22+
method: 'POST',
23+
body: 'not json'
24+
})
25+
26+
const res = await handler.POST(req)
27+
expect(res.status).toBe(400)
28+
})
29+
})

__test__/hydrationObserver.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, test, expect, mock } from 'bun:test'
2+
import { setupHydrationObserver } from '../src/domains/system/hydrationObserver'
3+
4+
describe('hydrationObserver', () => {
5+
test('ignores non-hydration errors', () => {
6+
const callback = mock(() => {})
7+
const cleanup = setupHydrationObserver(callback)
8+
9+
console.error('Some other error')
10+
expect(callback).not.toHaveBeenCalled()
11+
12+
cleanup()
13+
})
14+
15+
test('captures "Text content did not match" with server/client details', () => {
16+
const callback = mock(() => {})
17+
const cleanup = setupHydrationObserver(callback)
18+
19+
const msg = 'Text content did not match. Server: "Hello" Client: "World"'
20+
console.error(msg)
21+
22+
expect(callback).toHaveBeenCalledTimes(1)
23+
const calls = callback.mock.calls as any[]
24+
const error = calls[0][0]
25+
expect(error.message).toBe(msg)
26+
expect(error.serverContent).toBe('Hello')
27+
expect(error.clientContent).toBe('World')
28+
29+
cleanup()
30+
})
31+
32+
test('captures "Hydration failed because"', () => {
33+
const callback = mock(() => {})
34+
const cleanup = setupHydrationObserver(callback)
35+
36+
const msg = 'Hydration failed because the initial UI does not match what was rendered on the server.'
37+
console.error(msg)
38+
39+
expect(callback).toHaveBeenCalledTimes(1)
40+
const calls = callback.mock.calls as any[]
41+
expect(calls[0][0].message).toBe(msg)
42+
43+
cleanup()
44+
})
45+
46+
test('captures component stack from args', () => {
47+
const callback = mock(() => {})
48+
const cleanup = setupHydrationObserver(callback)
49+
50+
const stack = '\n in div\n in App'
51+
console.error('Text content did not match.', stack)
52+
53+
expect(callback).toHaveBeenCalledTimes(1)
54+
const calls = callback.mock.calls as any[]
55+
expect(calls[0][0].componentStack).toBe(stack)
56+
57+
cleanup()
58+
})
59+
})

__test__/index.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe('package entry point', () => {
99
})
1010

1111
test('exports FEATURE_IDS', () => {
12-
expect(FEATURE_IDS).toEqual(['auth', 'routes', 'performance', 'system', 'links'])
12+
expect(FEATURE_IDS).toEqual(['auth', 'routes', 'performance', 'system', 'links', 'monitor'])
1313
})
1414

1515
test('exports DEFAULTS', () => {
@@ -104,7 +104,7 @@ describe('eventBus', () => {
104104
describe('registry', () => {
105105
test('registers and retrieves domains', () => {
106106
const registry = createRegistry()
107-
const mockDomain = {
107+
const mockDomain: any = {
108108
id: 'auth' as FeatureId,
109109
setup() {},
110110
start() {},
@@ -120,7 +120,7 @@ describe('registry', () => {
120120

121121
test('tracks started state', () => {
122122
const registry = createRegistry()
123-
const mockDomain = {
123+
const mockDomain: any = {
124124
id: 'routes' as FeatureId,
125125
setup() {},
126126
start() {},

__test__/lifecycle.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { bootstrap } from '../src/core/bootstrap'
3+
4+
describe('runtime lifecycle', () => {
5+
test('start activates all registered domains', async () => {
6+
const runtime = bootstrap({})
7+
await runtime.start()
8+
const domains = runtime.registry.listDomains()
9+
for (const id of domains) {
10+
expect(runtime.registry.isStarted(id)).toBe(true)
11+
}
12+
})
13+
14+
test('stop then start works again', async () => {
15+
const runtime = bootstrap({})
16+
await runtime.start()
17+
await runtime.stop()
18+
await runtime.start()
19+
const domains = runtime.registry.listDomains()
20+
for (const id of domains) {
21+
expect(runtime.registry.isStarted(id)).toBe(true)
22+
}
23+
})
24+
25+
test('disabled features are not started', async () => {
26+
const runtime = bootstrap({ disable: ['auth', 'links'] })
27+
await runtime.start()
28+
expect(runtime.registry.isStarted('auth')).toBe(false)
29+
expect(runtime.registry.isStarted('links')).toBe(false)
30+
expect(runtime.registry.isStarted('performance')).toBe(true)
31+
})
32+
33+
test('plugin registration works', async () => {
34+
const runtime = bootstrap({})
35+
let called = false
36+
await runtime.registerPlugin({
37+
id: 'test-plugin',
38+
version: '0.0.1',
39+
register() {
40+
called = true
41+
}
42+
})
43+
expect(called).toBe(true)
44+
})
45+
})

__test__/linksDomain.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { createBus } from '../src/core/eventBus'
3+
import { createLinksDomain } from '../src/domains/links/linksDomain'
4+
5+
describe('createLinksDomain', () => {
6+
test('loads links from extractor and validates against routes', async () => {
7+
const bus = createBus()
8+
const domain = createLinksDomain(bus)
9+
10+
domain.setup({
11+
extractLinks: async function getLinks() {
12+
return [
13+
{ target: '/known', sourceFile: 'a.tsx', sourceType: 'next-link' },
14+
{ target: '/missing', sourceFile: 'b.tsx', sourceType: 'anchor' }
15+
]
16+
}
17+
})
18+
19+
bus.publish('routes/discovered', {
20+
count: 1,
21+
routes: [{ path: '/known', sourceFile: 'src/app/known/page.tsx', isDynamic: false }]
22+
})
23+
24+
await domain.start()
25+
const state = domain.getState()
26+
expect(state.links.length).toBeGreaterThan(0)
27+
expect(state.diagnostics.length).toBe(1)
28+
})
29+
})

0 commit comments

Comments
 (0)