@1agh/maude 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/design-link.test.mjs +46 -0
- package/cli/commands/doctor.mjs +110 -5
- package/cli/commands/doctor.test.mjs +52 -0
- package/cli/lib/design-link.mjs +38 -8
- package/package.json +8 -8
- package/plugins/design/dev-server/activity.ts +256 -0
- package/plugins/design/dev-server/ai-banner.tsx +4 -0
- package/plugins/design/dev-server/artboard-activity-overlay.tsx +67 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +164 -9
- package/plugins/design/dev-server/canvas-lib.tsx +62 -15
- package/plugins/design/dev-server/config.schema.json +2 -1
- package/plugins/design/dev-server/dist/client.bundle.js +18 -18
- package/plugins/design/dev-server/dist/comment-mount.js +82 -4
- package/plugins/design/dev-server/inspect.ts +31 -1
- package/plugins/design/dev-server/participants-chrome.tsx +86 -1
- package/plugins/design/dev-server/server.ts +10 -1
- package/plugins/design/dev-server/sync/agent.ts +23 -8
- package/plugins/design/dev-server/sync/index.ts +24 -19
- package/plugins/design/dev-server/test/activity.test.ts +195 -0
- package/plugins/design/dev-server/test/artboard-activity-overlay.test.tsx +56 -0
- package/plugins/design/dev-server/test/canvas-hmr-runtime.test.tsx +114 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +28 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +34 -6
- package/plugins/design/dev-server/test/use-agent-presence.test.tsx +114 -0
- package/plugins/design/dev-server/test/use-canvas-activity.test.tsx +206 -0
- package/plugins/design/dev-server/use-agent-presence.tsx +244 -0
- package/plugins/design/dev-server/use-canvas-activity.tsx +252 -0
- package/plugins/design/dev-server/ws.ts +21 -2
- package/plugins/design/templates/_shell.html +108 -3
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// canvas-hmr-runtime — Phase 13.1 / DDR-077. The resettable error boundary that
|
|
2
|
+
// keeps the last good render (no white flash) when an agent edit produces a
|
|
3
|
+
// broken intermediate, and recovers when a good build lands.
|
|
4
|
+
//
|
|
5
|
+
// Error boundaries don't run under renderToStaticMarkup (SSR rethrows), so this
|
|
6
|
+
// needs a live DOM. We register happy-dom for THIS file only (like
|
|
7
|
+
// annotations-roundtrip.test.ts) and drive a real createRoot.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
GlobalRegistrator.register();
|
|
14
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
});
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
GlobalRegistrator.unregister();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { act, createElement } from 'react';
|
|
21
|
+
import { type Root, createRoot } from 'react-dom/client';
|
|
22
|
+
|
|
23
|
+
import { CanvasErrorBoundary } from '../canvas-comment-mount.tsx';
|
|
24
|
+
|
|
25
|
+
function Good({ tag }: { tag: string }) {
|
|
26
|
+
return createElement('div', { 'data-good': tag }, `good:${tag}`);
|
|
27
|
+
}
|
|
28
|
+
function Boom(): never {
|
|
29
|
+
throw new Error('render exploded (missing import)');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mount(el: HTMLElement) {
|
|
33
|
+
let root!: Root;
|
|
34
|
+
act(() => {
|
|
35
|
+
root = createRoot(el);
|
|
36
|
+
});
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('CanvasErrorBoundary', () => {
|
|
41
|
+
test('renders children when they do not throw', () => {
|
|
42
|
+
const host = document.createElement('div');
|
|
43
|
+
const root = mount(host);
|
|
44
|
+
act(() => {
|
|
45
|
+
root.render(
|
|
46
|
+
createElement(
|
|
47
|
+
CanvasErrorBoundary,
|
|
48
|
+
{
|
|
49
|
+
attempt: 0,
|
|
50
|
+
onError: () => {},
|
|
51
|
+
fallback: () => createElement(Good, { tag: 'fallback' }),
|
|
52
|
+
},
|
|
53
|
+
createElement(Good, { tag: 'live' })
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
expect(host.querySelector('[data-good="live"]')).not.toBeNull();
|
|
58
|
+
expect(host.querySelector('[data-good="fallback"]')).toBeNull();
|
|
59
|
+
act(() => root.unmount());
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a throwing child → renders fallback (NOT blank) and fires onError once', () => {
|
|
63
|
+
const host = document.createElement('div');
|
|
64
|
+
const root = mount(host);
|
|
65
|
+
let errors = 0;
|
|
66
|
+
act(() => {
|
|
67
|
+
root.render(
|
|
68
|
+
createElement(
|
|
69
|
+
CanvasErrorBoundary,
|
|
70
|
+
{
|
|
71
|
+
attempt: 0,
|
|
72
|
+
onError: () => {
|
|
73
|
+
errors++;
|
|
74
|
+
},
|
|
75
|
+
fallback: () => createElement(Good, { tag: 'lastgood' }),
|
|
76
|
+
},
|
|
77
|
+
createElement(Boom)
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
// The boundary held a render (the fallback) — the host is NOT empty.
|
|
82
|
+
expect(host.querySelector('[data-good="lastgood"]')).not.toBeNull();
|
|
83
|
+
expect(host.textContent).toContain('good:lastgood');
|
|
84
|
+
expect(errors).toBe(1);
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a new attempt with a good child recovers (swaps fallback → live)', () => {
|
|
89
|
+
const host = document.createElement('div');
|
|
90
|
+
const root = mount(host);
|
|
91
|
+
const render = (attempt: number, child: ReturnType<typeof createElement>) =>
|
|
92
|
+
act(() => {
|
|
93
|
+
root.render(
|
|
94
|
+
createElement(
|
|
95
|
+
CanvasErrorBoundary,
|
|
96
|
+
{
|
|
97
|
+
attempt,
|
|
98
|
+
onError: () => {},
|
|
99
|
+
fallback: () => createElement(Good, { tag: 'lastgood' }),
|
|
100
|
+
},
|
|
101
|
+
child
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
render(0, createElement(Boom)); // broken build → fallback
|
|
107
|
+
expect(host.querySelector('[data-good="lastgood"]')).not.toBeNull();
|
|
108
|
+
|
|
109
|
+
render(1, createElement(Good, { tag: 'fixed' })); // good build lands
|
|
110
|
+
expect(host.querySelector('[data-good="fixed"]')).not.toBeNull();
|
|
111
|
+
expect(host.querySelector('[data-good="lastgood"]')).toBeNull();
|
|
112
|
+
act(() => root.unmount());
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -202,6 +202,34 @@ describe('CanvasSyncAgent — cold start reconciliation', () => {
|
|
|
202
202
|
expect(htmlFromDoc(docB)).toBe('<button>hub-v2</button>');
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
test('empty hub doc does NOT clobber a non-empty local body — seeds local up instead (data-loss guard)', async () => {
|
|
206
|
+
// Local holds the real canvas body; the hub is fresh — no state for this
|
|
207
|
+
// slug yet (docB starts empty → docHtml === ''). The old behaviour wrote
|
|
208
|
+
// the empty doc over disk and emptied the file; the guard must keep local.
|
|
209
|
+
writeFileSync(paths().html, '<button>real-canvas</button>');
|
|
210
|
+
|
|
211
|
+
let conflicts = 0;
|
|
212
|
+
agent = createCanvasSyncAgent({
|
|
213
|
+
slug: 'screen',
|
|
214
|
+
doc: docB,
|
|
215
|
+
paths: paths(),
|
|
216
|
+
echoGuard: createEchoGuard(),
|
|
217
|
+
flushMs: 0,
|
|
218
|
+
onConflict: () => {
|
|
219
|
+
conflicts++;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
agent.start();
|
|
223
|
+
await agent.reconcile();
|
|
224
|
+
|
|
225
|
+
// The local body MUST survive — an empty hub must never empty a real canvas.
|
|
226
|
+
expect(readFileSync(paths().html, 'utf8')).toBe('<button>real-canvas</button>');
|
|
227
|
+
// …and it is seeded UP to the hub instead of being discarded (local→doc).
|
|
228
|
+
expect(htmlFromDoc(docA)).toBe('<button>real-canvas</button>');
|
|
229
|
+
// An empty-hub seed is not a "hub overwrote your local" conflict.
|
|
230
|
+
expect(conflicts).toBe(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
205
233
|
test('identical disk + doc states: no disk write', async () => {
|
|
206
234
|
writeFileSync(paths().html, '<button>same</button>');
|
|
207
235
|
applyHtmlToDoc(docA, '<button>same</button>');
|
|
@@ -783,11 +783,11 @@ describe('scanCanvases', () => {
|
|
|
783
783
|
});
|
|
784
784
|
});
|
|
785
785
|
|
|
786
|
-
// DDR-072 —
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
describe('scanCanvases — project-level syncTsx (DDR-
|
|
790
|
-
test('syncTsx:true + sandbox ON admits a .tsx WITHOUT a per-canvas opt-in', async () => {
|
|
786
|
+
// DDR-079 (was DDR-072) — TSX sync defaults ON. A .tsx with no explicit sidecar
|
|
787
|
+
// verdict syncs by default; `syncTsx: false` opts the project out; the per-canvas
|
|
788
|
+
// sidecar still wins; the Lock-2 sandbox coupling is preserved.
|
|
789
|
+
describe('scanCanvases — project-level syncTsx default-on (DDR-079)', () => {
|
|
790
|
+
test('explicit syncTsx:true + sandbox ON admits a .tsx WITHOUT a per-canvas opt-in', async () => {
|
|
791
791
|
const ctx = makeCtx(
|
|
792
792
|
{ url: 'https://h.example.com', linkedAt: 1, syncTsx: true },
|
|
793
793
|
'http://localhost:9'
|
|
@@ -826,13 +826,41 @@ describe('scanCanvases — project-level syncTsx (DDR-072)', () => {
|
|
|
826
826
|
expect(slugs).not.toContain('ui-secret'); // explicit opt-out wins
|
|
827
827
|
});
|
|
828
828
|
|
|
829
|
-
test('without the flag (syncTsx absent) a .tsx
|
|
829
|
+
test('DDR-079: without the flag (syncTsx absent) a .tsx is admitted BY DEFAULT', async () => {
|
|
830
830
|
const ctx = makeCtx({ url: 'https://h.example.com', linkedAt: 1 }, 'http://localhost:9');
|
|
831
831
|
writeFileSync(join(ctx.paths.designRoot, 'ui', 'plain.tsx'), 'export default () => null;');
|
|
832
832
|
const scan = await scanCanvases(ctx);
|
|
833
|
+
expect(scan.canvases.map((c) => c.slug)).toContain('ui-plain'); // default ON
|
|
834
|
+
expect(scan.tsxCount).toBe(0);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test('DDR-079: syncTsx:false opts the whole project OUT (.tsx not admitted)', async () => {
|
|
838
|
+
const ctx = makeCtx(
|
|
839
|
+
{ url: 'https://h.example.com', linkedAt: 1, syncTsx: false },
|
|
840
|
+
'http://localhost:9'
|
|
841
|
+
);
|
|
842
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'plain.tsx'), 'export default () => null;');
|
|
843
|
+
const scan = await scanCanvases(ctx);
|
|
833
844
|
expect(scan.canvases.map((c) => c.slug)).not.toContain('ui-plain');
|
|
834
845
|
expect(scan.tsxCount).toBe(1);
|
|
835
846
|
});
|
|
847
|
+
|
|
848
|
+
test('DDR-079: per-canvas "syncable": true still admits one .tsx even when project opted out', async () => {
|
|
849
|
+
const ctx = makeCtx(
|
|
850
|
+
{ url: 'https://h.example.com', linkedAt: 1, syncTsx: false },
|
|
851
|
+
'http://localhost:9'
|
|
852
|
+
);
|
|
853
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'keep.tsx'), 'export default () => null;');
|
|
854
|
+
writeFileSync(
|
|
855
|
+
join(ctx.paths.designRoot, 'ui', 'keep.meta.json'),
|
|
856
|
+
JSON.stringify({ syncable: true, title: 'Keep' })
|
|
857
|
+
);
|
|
858
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'drop.tsx'), 'export default () => null;');
|
|
859
|
+
const scan = await scanCanvases(ctx);
|
|
860
|
+
const slugs = scan.canvases.map((c) => c.slug);
|
|
861
|
+
expect(slugs).toContain('ui-keep'); // sidecar opt-in wins over project opt-out
|
|
862
|
+
expect(slugs).not.toContain('ui-drop'); // project opted out
|
|
863
|
+
});
|
|
836
864
|
});
|
|
837
865
|
|
|
838
866
|
describe('buildNoSyncablePayload', () => {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// use-agent-presence — Phase 13.2. Funny-name/color derivation + provider gating.
|
|
2
|
+
// Live wiring (postMessage / WS subscription) is skipped via the `initialAgent`
|
|
3
|
+
// seed, so this stays SSR-renderable (renderToStaticMarkup capture pattern).
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type AgentPresence,
|
|
12
|
+
AgentPresenceProvider,
|
|
13
|
+
agentFunnyName,
|
|
14
|
+
deriveAgent,
|
|
15
|
+
useAgentPresence,
|
|
16
|
+
} from '../use-agent-presence.tsx';
|
|
17
|
+
|
|
18
|
+
describe('use-agent-presence / agentFunnyName', () => {
|
|
19
|
+
test('deterministic — same seed → same name', () => {
|
|
20
|
+
expect(agentFunnyName('Claude:1700000000000')).toBe(agentFunnyName('Claude:1700000000000'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('shape is "Adjective Animal"', () => {
|
|
24
|
+
expect(agentFunnyName('seed-x')).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('different seeds usually differ (no constant collapse)', () => {
|
|
28
|
+
const names = new Set(
|
|
29
|
+
Array.from({ length: 24 }, (_, i) => agentFunnyName(`Claude:${1700000000000 + i * 7919}`))
|
|
30
|
+
);
|
|
31
|
+
// Not all 24 identical — the generator actually spreads.
|
|
32
|
+
expect(names.size).toBeGreaterThan(5);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('empty seed is handled (no throw, stable)', () => {
|
|
36
|
+
expect(agentFunnyName('')).toBe(agentFunnyName(''));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('use-agent-presence / deriveAgent', () => {
|
|
41
|
+
const entry = {
|
|
42
|
+
file: 'ui/Foo.tsx',
|
|
43
|
+
author: 'Claude (acting for Ada)',
|
|
44
|
+
startedAt: 1700000000000,
|
|
45
|
+
lastHeartbeat: 1700000000000,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
test('id = author:startedAt; name = funny; color present; author preserved', () => {
|
|
49
|
+
const a = deriveAgent(entry);
|
|
50
|
+
expect(a.id).toBe('Claude (acting for Ada):1700000000000');
|
|
51
|
+
expect(a.name).toBe(agentFunnyName(a.id));
|
|
52
|
+
expect(a.author).toBe('Claude (acting for Ada)');
|
|
53
|
+
expect(a.startedAt).toBe(1700000000000);
|
|
54
|
+
expect(typeof a.color).toBe('string');
|
|
55
|
+
expect(a.color.length).toBeGreaterThan(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('same entry → identical derived agent (stable across heartbeats)', () => {
|
|
59
|
+
expect(deriveAgent(entry)).toEqual(deriveAgent({ ...entry, lastHeartbeat: 9999999999999 }));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('use-agent-presence / provider gating', () => {
|
|
64
|
+
function Wrap({
|
|
65
|
+
initialAgent,
|
|
66
|
+
children,
|
|
67
|
+
}: { initialAgent: AgentPresence | null; children: ReactNode }) {
|
|
68
|
+
return <AgentPresenceProvider initialAgent={initialAgent}>{children}</AgentPresenceProvider>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('seeded agent flows through the hook', () => {
|
|
72
|
+
const seeded = deriveAgent({
|
|
73
|
+
file: 'ui/Foo.tsx',
|
|
74
|
+
author: 'Claude',
|
|
75
|
+
startedAt: 42,
|
|
76
|
+
lastHeartbeat: 42,
|
|
77
|
+
});
|
|
78
|
+
let got: AgentPresence | null = null;
|
|
79
|
+
function Probe() {
|
|
80
|
+
got = useAgentPresence();
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
renderToStaticMarkup(
|
|
84
|
+
<Wrap initialAgent={seeded}>
|
|
85
|
+
<Probe />
|
|
86
|
+
</Wrap>
|
|
87
|
+
);
|
|
88
|
+
expect(got).toEqual(seeded);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('null seed → no agent', () => {
|
|
92
|
+
let got: AgentPresence | null = { id: 'x' } as AgentPresence;
|
|
93
|
+
function Probe() {
|
|
94
|
+
got = useAgentPresence();
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
renderToStaticMarkup(
|
|
98
|
+
<Wrap initialAgent={null}>
|
|
99
|
+
<Probe />
|
|
100
|
+
</Wrap>
|
|
101
|
+
);
|
|
102
|
+
expect(got).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('outside the provider the hook is inert (null)', () => {
|
|
106
|
+
let got: AgentPresence | null = { id: 'x' } as AgentPresence;
|
|
107
|
+
function Probe() {
|
|
108
|
+
got = useAgentPresence();
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
renderToStaticMarkup(<Probe />);
|
|
112
|
+
expect(got).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// use-canvas-activity — Phase 13. Pure reducer/key helpers + provider gating.
|
|
2
|
+
//
|
|
3
|
+
// bun:test runs without a live DOM renderer, so dynamic event-driven state
|
|
4
|
+
// (the `maude:activity` listener + fade timers) lives in the provider's
|
|
5
|
+
// useEffect and can't be driven here. We unit-test the pure core
|
|
6
|
+
// (applyActivityChange / activityKey / matchesArtboard) directly, and verify
|
|
7
|
+
// the provider → hook derivation via the SSR-capture pattern with `initialState`
|
|
8
|
+
// (which is also the WS-snapshot seed path).
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type ActivityMessage,
|
|
17
|
+
type CanvasActivity,
|
|
18
|
+
CanvasActivityProvider,
|
|
19
|
+
activityKey,
|
|
20
|
+
applyActivityChange,
|
|
21
|
+
matchesArtboard,
|
|
22
|
+
useCanvasActivity,
|
|
23
|
+
} from '../use-canvas-activity.tsx';
|
|
24
|
+
|
|
25
|
+
describe('use-canvas-activity / activityKey', () => {
|
|
26
|
+
test('canonical design-root-relative path is unchanged', () => {
|
|
27
|
+
expect(activityKey('ui/Foo.tsx')).toBe('ui/Foo.tsx');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('strips a known designRel prefix', () => {
|
|
31
|
+
expect(activityKey('.design/ui/Foo.tsx', '.design')).toBe('ui/Foo.tsx');
|
|
32
|
+
expect(activityKey('.design/ui/Foo.tsx', './.design')).toBe('ui/Foo.tsx');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('normalizes back-slash + leading slash', () => {
|
|
36
|
+
expect(activityKey('\\ui\\Foo.tsx')).toBe('ui/Foo.tsx');
|
|
37
|
+
expect(activityKey('/ui/Foo.tsx')).toBe('ui/Foo.tsx');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('leaves the path alone when designRel does not match', () => {
|
|
41
|
+
expect(activityKey('ui/Foo.tsx', '.design')).toBe('ui/Foo.tsx');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('use-canvas-activity / applyActivityChange', () => {
|
|
46
|
+
const base: ActivityMessage = { file: 'ui/Foo.tsx', status: 'active', ts: 't1' };
|
|
47
|
+
|
|
48
|
+
test('adds an active entry', () => {
|
|
49
|
+
const next = applyActivityChange({}, base);
|
|
50
|
+
expect(next['ui/Foo.tsx']).toEqual({ status: 'active', artboardIds: null, ts: 't1' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('idle overwrites the same key', () => {
|
|
54
|
+
const a = applyActivityChange({}, base);
|
|
55
|
+
const b = applyActivityChange(a, { file: 'ui/Foo.tsx', status: 'idle', ts: 't2' });
|
|
56
|
+
expect(b['ui/Foo.tsx']?.status).toBe('idle');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('carries artboard_ids → artboardIds', () => {
|
|
60
|
+
const next = applyActivityChange({}, { ...base, artboard_ids: ['secondary'] });
|
|
61
|
+
expect(next['ui/Foo.tsx']?.artboardIds).toEqual(['secondary']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('different files coexist; immutable update', () => {
|
|
65
|
+
const a = applyActivityChange({}, base);
|
|
66
|
+
const b = applyActivityChange(a, { file: 'ui/Bar.tsx', status: 'active', ts: 't3' });
|
|
67
|
+
expect(Object.keys(b).sort()).toEqual(['ui/Bar.tsx', 'ui/Foo.tsx']);
|
|
68
|
+
expect(b).not.toBe(a);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('ignores a malformed message (no file)', () => {
|
|
72
|
+
const a = applyActivityChange({}, base);
|
|
73
|
+
const b = applyActivityChange(a, { file: '', status: 'active', ts: 'x' });
|
|
74
|
+
expect(b).toBe(a);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('use-canvas-activity / matchesArtboard', () => {
|
|
79
|
+
test('null scope matches every artboard', () => {
|
|
80
|
+
expect(matchesArtboard(null, 'primary')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
test('scoped list matches only listed ids', () => {
|
|
83
|
+
expect(matchesArtboard(['secondary'], 'secondary')).toBe(true);
|
|
84
|
+
expect(matchesArtboard(['secondary'], 'primary')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('use-canvas-activity / provider → hook derivation', () => {
|
|
89
|
+
function capture(node: (v: CanvasActivity) => void, props: Parameters<typeof Wrap>[0]) {
|
|
90
|
+
function Probe() {
|
|
91
|
+
node(useCanvasActivity());
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
renderToStaticMarkup(<Wrap {...props}>{<Probe />}</Wrap>);
|
|
95
|
+
}
|
|
96
|
+
function Wrap(props: {
|
|
97
|
+
file?: string;
|
|
98
|
+
designRel?: string;
|
|
99
|
+
initialState?: Record<
|
|
100
|
+
string,
|
|
101
|
+
{ status: 'active' | 'idle'; artboardIds: string[] | null; ts: string }
|
|
102
|
+
>;
|
|
103
|
+
children: ReactNode;
|
|
104
|
+
}) {
|
|
105
|
+
return (
|
|
106
|
+
<CanvasActivityProvider
|
|
107
|
+
file={props.file}
|
|
108
|
+
designRel={props.designRel}
|
|
109
|
+
initialState={props.initialState}
|
|
110
|
+
>
|
|
111
|
+
{props.children}
|
|
112
|
+
</CanvasActivityProvider>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test('active entry for the current canvas → present + active', () => {
|
|
117
|
+
let v: CanvasActivity | null = null;
|
|
118
|
+
capture(
|
|
119
|
+
(x) => {
|
|
120
|
+
v = x;
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
file: 'ui/Test.tsx',
|
|
124
|
+
initialState: { 'ui/Test.tsx': { status: 'active', artboardIds: null, ts: 't' } },
|
|
125
|
+
children: null,
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
expect(v).toMatchObject({
|
|
129
|
+
present: true,
|
|
130
|
+
active: true,
|
|
131
|
+
artboardIds: null,
|
|
132
|
+
fileLabel: 'Test.tsx',
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('idle entry → present but not active (fading out)', () => {
|
|
137
|
+
let v: CanvasActivity | null = null;
|
|
138
|
+
capture(
|
|
139
|
+
(x) => {
|
|
140
|
+
v = x;
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
file: 'ui/Test.tsx',
|
|
144
|
+
initialState: { 'ui/Test.tsx': { status: 'idle', artboardIds: null, ts: 't' } },
|
|
145
|
+
children: null,
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
expect(v).toMatchObject({ present: true, active: false });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('no entry for this canvas → inert', () => {
|
|
152
|
+
let v: CanvasActivity | null = null;
|
|
153
|
+
capture(
|
|
154
|
+
(x) => {
|
|
155
|
+
v = x;
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
file: 'ui/Test.tsx',
|
|
159
|
+
initialState: { 'ui/Other.tsx': { status: 'active', artboardIds: null, ts: 't' } },
|
|
160
|
+
children: null,
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
expect(v).toMatchObject({ present: false, active: false });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('scoped artboardIds flow through', () => {
|
|
167
|
+
let v: CanvasActivity | null = null;
|
|
168
|
+
capture(
|
|
169
|
+
(x) => {
|
|
170
|
+
v = x;
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
file: 'ui/Test.tsx',
|
|
174
|
+
initialState: { 'ui/Test.tsx': { status: 'active', artboardIds: ['secondary'], ts: 't' } },
|
|
175
|
+
children: null,
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
expect(v?.artboardIds).toEqual(['secondary']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('designRel-prefixed current file is normalized to match server keys', () => {
|
|
182
|
+
let v: CanvasActivity | null = null;
|
|
183
|
+
capture(
|
|
184
|
+
(x) => {
|
|
185
|
+
v = x;
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
file: '.design/ui/Test.tsx',
|
|
189
|
+
designRel: '.design',
|
|
190
|
+
initialState: { 'ui/Test.tsx': { status: 'active', artboardIds: null, ts: 't' } },
|
|
191
|
+
children: null,
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
expect(v).toMatchObject({ present: true, active: true });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('outside a provider the hook is inert (no overlay)', () => {
|
|
198
|
+
let v: CanvasActivity | null = null;
|
|
199
|
+
function Probe() {
|
|
200
|
+
v = useCanvasActivity();
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
renderToStaticMarkup(<Probe />);
|
|
204
|
+
expect(v).toMatchObject({ present: false, active: false });
|
|
205
|
+
});
|
|
206
|
+
});
|