@1agh/maude 0.23.0 → 0.25.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/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +208 -23
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +208 -18
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Phase 9.2 (DDR-064) Phase C — loop-free disk projection (Tasks 6 + 7).
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for sync/projection.ts in isolation (no runtime / no hub). They
|
|
4
|
+
// pin the contract the shared-doc cutover relies on:
|
|
5
|
+
// - doc→file projects html/css/meta only (the collab room owns comments +
|
|
6
|
+
// annotations doc→file — no double-write);
|
|
7
|
+
// - file→doc imports all five types as a minimal DIFF (never wholesale),
|
|
8
|
+
// tagged FILE_IMPORT so it doesn't echo back to disk;
|
|
9
|
+
// - our own doc→file writes are dropped on their fs echo (hash guard);
|
|
10
|
+
// - a body file edit does NOT clobber concurrent in-doc comments (independent
|
|
11
|
+
// Y-types — the cross-type no-clobber guarantee);
|
|
12
|
+
// - an unparseable file is quarantined after 3 strikes (circuit breaker).
|
|
13
|
+
//
|
|
14
|
+
// NB: true char-level merge of CONCURRENT edits to the SAME body region is
|
|
15
|
+
// Phase 10 (structured CRDT). Here the body is opaque Y.Text + prefix/suffix
|
|
16
|
+
// diff: it preserves untouched regions, but a whole-file /design:edit that omits
|
|
17
|
+
// a concurrent browser body edit will still supersede it. Browsers edit
|
|
18
|
+
// comments/annotations (separate types), so that residual is out of Phase C
|
|
19
|
+
// scope by design.
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test } from 'bun:test';
|
|
22
|
+
import * as Y from 'yjs';
|
|
23
|
+
|
|
24
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
25
|
+
import { MAX_HTML_BYTES, applyHtmlToDoc } from '../sync/codec.ts';
|
|
26
|
+
import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
|
|
27
|
+
import { ORIGINS } from '../sync/origins.ts';
|
|
28
|
+
import { createDocProjection } from '../sync/projection.ts';
|
|
29
|
+
|
|
30
|
+
const enc = (s: string) => new TextEncoder().encode(s);
|
|
31
|
+
|
|
32
|
+
interface Write {
|
|
33
|
+
path: string;
|
|
34
|
+
bytes: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeProjection(paths: {
|
|
38
|
+
html: string;
|
|
39
|
+
comments: string;
|
|
40
|
+
annotations: string;
|
|
41
|
+
meta?: string;
|
|
42
|
+
css?: string;
|
|
43
|
+
}) {
|
|
44
|
+
const doc = new Y.Doc();
|
|
45
|
+
const writes: Write[] = [];
|
|
46
|
+
const echoGuard = createEchoGuard();
|
|
47
|
+
const projection = createDocProjection({
|
|
48
|
+
slug: 'p',
|
|
49
|
+
doc,
|
|
50
|
+
paths,
|
|
51
|
+
echoGuard,
|
|
52
|
+
flushMs: 0, // synchronous flush on the next microtask
|
|
53
|
+
writer: (path, bytes) => writes.push({ path, bytes: bytes.toString() }),
|
|
54
|
+
});
|
|
55
|
+
projection.start();
|
|
56
|
+
return { doc, writes, echoGuard, projection };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PATHS = {
|
|
60
|
+
html: '/d/ui/screen.html',
|
|
61
|
+
comments: '/d/_comments/ui-screen.json',
|
|
62
|
+
annotations: '/d/ui-screen.annotations.svg',
|
|
63
|
+
meta: '/d/ui/screen.meta.json',
|
|
64
|
+
css: '/d/ui/screen.css',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
68
|
+
|
|
69
|
+
describe('projection doc→file (html/css/meta only — room owns comments/annotations)', () => {
|
|
70
|
+
test('projects html / css / meta on a doc edit', async () => {
|
|
71
|
+
const { doc, writes, projection } = makeProjection(PATHS);
|
|
72
|
+
doc.getText('html').insert(0, '<button>hi</button>');
|
|
73
|
+
doc.getText('css').insert(0, '.b{color:red}');
|
|
74
|
+
// meta is stored as the canonical SHARED subset (a JSON string in Y.Text).
|
|
75
|
+
doc.getText('meta').insert(0, JSON.stringify({ title: 'Screen' }));
|
|
76
|
+
await projection.flush();
|
|
77
|
+
|
|
78
|
+
const byPath = Object.fromEntries(writes.map((w) => [w.path, w.bytes]));
|
|
79
|
+
expect(byPath[PATHS.html]).toBe('<button>hi</button>');
|
|
80
|
+
expect(byPath[PATHS.css]).toBe('.b{color:red}');
|
|
81
|
+
expect(JSON.parse(byPath[PATHS.meta])).toMatchObject({ title: 'Screen' });
|
|
82
|
+
await tick();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('does NOT write comments or annotations doc→file (the room owns those)', async () => {
|
|
86
|
+
const { doc, writes, projection } = makeProjection(PATHS);
|
|
87
|
+
doc.getArray(Y_TYPES.comments).push([{ id: 'c1', text: 'hi' }]);
|
|
88
|
+
doc.getMap(Y_TYPES.annotations).set('svg', '<svg/>');
|
|
89
|
+
await projection.flush();
|
|
90
|
+
expect(writes.find((w) => w.path === PATHS.comments)).toBeUndefined();
|
|
91
|
+
expect(writes.find((w) => w.path === PATHS.annotations)).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('never clobbers a non-empty body with an empty doc value (safe reconcile)', async () => {
|
|
95
|
+
const { writes, projection } = makeProjection(PATHS);
|
|
96
|
+
// doc html is empty → reconcile must not write an empty file over local.
|
|
97
|
+
projection.reconcile();
|
|
98
|
+
expect(writes.find((w) => w.path === PATHS.html)).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('projection file→doc (diff-import, all five types, FILE_IMPORT origin)', () => {
|
|
103
|
+
test('imports an external body edit into the doc', () => {
|
|
104
|
+
const { doc, projection } = makeProjection(PATHS);
|
|
105
|
+
const body = '<main>edited</main>';
|
|
106
|
+
const changed = projection.applyFromFs({
|
|
107
|
+
path: PATHS.html,
|
|
108
|
+
bytes: enc(body),
|
|
109
|
+
hash: hashBytes(body),
|
|
110
|
+
});
|
|
111
|
+
expect(changed).toBe(true);
|
|
112
|
+
expect(doc.getText('html').toString()).toBe(body);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('import is tagged FILE_IMPORT (so the projector does not re-project it)', () => {
|
|
116
|
+
const { doc, projection } = makeProjection(PATHS);
|
|
117
|
+
let lastOrigin: unknown;
|
|
118
|
+
doc.on('update', (_u: Uint8Array, origin: unknown) => {
|
|
119
|
+
lastOrigin = origin;
|
|
120
|
+
});
|
|
121
|
+
const body = '<main>x</main>';
|
|
122
|
+
projection.applyFromFs({ path: PATHS.html, bytes: enc(body), hash: hashBytes(body) });
|
|
123
|
+
expect(lastOrigin).toBe(ORIGINS.FILE_IMPORT);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('imports comments + annotations too', () => {
|
|
127
|
+
const { doc, projection } = makeProjection(PATHS);
|
|
128
|
+
const comments = JSON.stringify([{ id: 'x', text: 'yo' }]);
|
|
129
|
+
expect(
|
|
130
|
+
projection.applyFromFs({
|
|
131
|
+
path: PATHS.comments,
|
|
132
|
+
bytes: enc(comments),
|
|
133
|
+
hash: hashBytes(comments),
|
|
134
|
+
})
|
|
135
|
+
).toBe(true);
|
|
136
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'x', text: 'yo' }]);
|
|
137
|
+
|
|
138
|
+
const svg = '<svg><rect/></svg>';
|
|
139
|
+
expect(
|
|
140
|
+
projection.applyFromFs({ path: PATHS.annotations, bytes: enc(svg), hash: hashBytes(svg) })
|
|
141
|
+
).toBe(true);
|
|
142
|
+
expect(doc.getMap(Y_TYPES.annotations).get('svg')).toBe(svg);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('body import is a MINIMAL diff — preserves an untouched suffix region', () => {
|
|
146
|
+
const { doc, projection } = makeProjection(PATHS);
|
|
147
|
+
doc.getText('html').insert(0, 'HELLO WORLD');
|
|
148
|
+
// Change only the prefix; the diff must keep " WORLD" in place (prefix/suffix
|
|
149
|
+
// elimination), not delete-all + insert-all.
|
|
150
|
+
const next = 'HOWDY WORLD';
|
|
151
|
+
projection.applyFromFs({ path: PATHS.html, bytes: enc(next), hash: hashBytes(next) });
|
|
152
|
+
expect(doc.getText('html').toString()).toBe(next);
|
|
153
|
+
// Cross-check the codec produced a minimal op (the same primitive the
|
|
154
|
+
// projector uses) — a wholesale replace would not preserve the suffix.
|
|
155
|
+
const probe = new Y.Doc();
|
|
156
|
+
probe.getText('html').insert(0, 'HELLO WORLD');
|
|
157
|
+
expect(applyHtmlToDoc(probe, next, 'x')).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('projection loop-freedom', () => {
|
|
162
|
+
test('drops the fs echo of our own doc→file write (hash guard)', async () => {
|
|
163
|
+
const { doc, projection } = makeProjection(PATHS);
|
|
164
|
+
doc.getText('html').insert(0, '<x/>');
|
|
165
|
+
await projection.flush();
|
|
166
|
+
// The fs.watch fires for our own write — same bytes → same hash → dropped,
|
|
167
|
+
// doc unchanged, no re-import.
|
|
168
|
+
let updates = 0;
|
|
169
|
+
doc.on('update', () => {
|
|
170
|
+
updates++;
|
|
171
|
+
});
|
|
172
|
+
const changed = projection.applyFromFs({
|
|
173
|
+
path: PATHS.html,
|
|
174
|
+
bytes: enc('<x/>'),
|
|
175
|
+
hash: hashBytes('<x/>'),
|
|
176
|
+
});
|
|
177
|
+
expect(changed).toBe(false);
|
|
178
|
+
expect(updates).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('a body file edit does NOT clobber concurrent in-doc comments (cross-type no-clobber)', () => {
|
|
182
|
+
const { doc, projection } = makeProjection(PATHS);
|
|
183
|
+
// Browser added a comment (lives in the comments Y.Array).
|
|
184
|
+
doc.getArray(Y_TYPES.comments).push([{ id: 'live', text: 'mine' }]);
|
|
185
|
+
// Concurrently /design:edit rewrites the whole body file.
|
|
186
|
+
const body = '<main>rewritten by design:edit</main>';
|
|
187
|
+
projection.applyFromFs({ path: PATHS.html, bytes: enc(body), hash: hashBytes(body) });
|
|
188
|
+
// Body updated; the concurrent comment SURVIVED (independent Y-type).
|
|
189
|
+
expect(doc.getText('html').toString()).toBe(body);
|
|
190
|
+
expect(doc.getArray(Y_TYPES.comments).toArray()).toEqual([{ id: 'live', text: 'mine' }]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('projection hub→disk hardening (security re-audit A2 + A3)', () => {
|
|
195
|
+
test('A2 — refuses to materialize an oversized (hub-pushed) body to disk', async () => {
|
|
196
|
+
const { doc, writes, projection } = makeProjection(PATHS);
|
|
197
|
+
// Simulate a hub raw-update that bypassed the codec import cap: an oversized
|
|
198
|
+
// body lands directly in the shared doc.
|
|
199
|
+
doc.getText('html').insert(0, 'x'.repeat(MAX_HTML_BYTES + 1));
|
|
200
|
+
await projection.flush();
|
|
201
|
+
// The doc→file lane must refuse it — no disk-fill DoS.
|
|
202
|
+
expect(writes.find((w) => w.path === PATHS.html)).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('A3 — a meta file with __proto__ is imported without polluting Object.prototype', () => {
|
|
206
|
+
const { doc, projection } = makeProjection(PATHS);
|
|
207
|
+
const malicious = JSON.stringify({ __proto__: { polluted: 'yes' }, title: 'ok' });
|
|
208
|
+
projection.applyFromFs({ path: PATHS.meta, bytes: enc(malicious), hash: hashBytes(malicious) });
|
|
209
|
+
// The dangerous key was stripped at parse time — no prototype pollution and
|
|
210
|
+
// the stored shared meta carries only the safe key.
|
|
211
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
212
|
+
const stored = doc.getText('meta').toString();
|
|
213
|
+
expect(stored).not.toContain('__proto__');
|
|
214
|
+
expect(stored).not.toContain('polluted');
|
|
215
|
+
expect(stored).toContain('ok');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('projection circuit breaker', () => {
|
|
220
|
+
test('quarantines an unparseable meta file after 3 strikes (until checksum changes)', () => {
|
|
221
|
+
const { doc, projection } = makeProjection(PATHS);
|
|
222
|
+
const broken = '{ not json';
|
|
223
|
+
const ev = { path: PATHS.meta, bytes: enc(broken), hash: hashBytes(broken) };
|
|
224
|
+
// First three attempts: parse fails → false, strikes accumulate.
|
|
225
|
+
expect(projection.applyFromFs(ev)).toBe(false);
|
|
226
|
+
expect(projection.applyFromFs(ev)).toBe(false);
|
|
227
|
+
expect(projection.applyFromFs(ev)).toBe(false);
|
|
228
|
+
// Now quarantined for that hash — still false, but short-circuited.
|
|
229
|
+
expect(projection.applyFromFs(ev)).toBe(false);
|
|
230
|
+
|
|
231
|
+
// A DIFFERENT (valid) payload clears the quarantine and applies.
|
|
232
|
+
const ok = JSON.stringify({ title: 'Fixed' });
|
|
233
|
+
expect(projection.applyFromFs({ path: PATHS.meta, bytes: enc(ok), hash: hashBytes(ok) })).toBe(
|
|
234
|
+
true
|
|
235
|
+
);
|
|
236
|
+
expect(doc.getText('meta').toString()).toContain('Fixed');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Connection-state machine tests — Phase 9 Task 8 (hub-down offline mode).
|
|
2
|
+
//
|
|
3
|
+
// Drives the monitor with injected timers + clock so transitions are
|
|
4
|
+
// deterministic without real wall-clock waits.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { type SyncStatusSnapshot, createConnectionMonitor } from '../sync/connection-state.ts';
|
|
9
|
+
|
|
10
|
+
/** A controllable timer queue: setTimer enqueues, advance() fires due timers. */
|
|
11
|
+
function fakeClock() {
|
|
12
|
+
let nowMs = 1_000_000;
|
|
13
|
+
let nextId = 1;
|
|
14
|
+
const timers = new Map<number, { fireAt: number; cb: () => void }>();
|
|
15
|
+
return {
|
|
16
|
+
now: () => nowMs,
|
|
17
|
+
setTimer: (cb: () => void, ms: number) => {
|
|
18
|
+
const id = nextId++;
|
|
19
|
+
timers.set(id, { fireAt: nowMs + ms, cb });
|
|
20
|
+
return id as unknown as ReturnType<typeof setTimeout>;
|
|
21
|
+
},
|
|
22
|
+
clearTimer: (h: ReturnType<typeof setTimeout>) => {
|
|
23
|
+
timers.delete(h as unknown as number);
|
|
24
|
+
},
|
|
25
|
+
/** Advance virtual time by `ms`, firing every timer whose deadline passed. */
|
|
26
|
+
advance(ms: number) {
|
|
27
|
+
nowMs += ms;
|
|
28
|
+
for (const [id, t] of [...timers.entries()].sort((a, b) => a[1].fireAt - b[1].fireAt)) {
|
|
29
|
+
if (t.fireAt <= nowMs) {
|
|
30
|
+
timers.delete(id);
|
|
31
|
+
t.cb();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeMonitor(overrides = {}) {
|
|
39
|
+
const clock = fakeClock();
|
|
40
|
+
const changes: SyncStatusSnapshot[] = [];
|
|
41
|
+
const monitor = createConnectionMonitor({
|
|
42
|
+
graceMs: 30_000,
|
|
43
|
+
escalateMs: 24 * 60 * 60 * 1000,
|
|
44
|
+
flashMs: 3_000,
|
|
45
|
+
now: clock.now,
|
|
46
|
+
setTimer: clock.setTimer,
|
|
47
|
+
clearTimer: clock.clearTimer,
|
|
48
|
+
onChange: (s) => changes.push(s),
|
|
49
|
+
...overrides,
|
|
50
|
+
});
|
|
51
|
+
return { clock, changes, monitor };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('connection monitor', () => {
|
|
55
|
+
test('starts online and stays online when the provider connects', () => {
|
|
56
|
+
const { monitor } = makeMonitor();
|
|
57
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
58
|
+
expect(monitor.snapshot().state).toBe('online');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('disconnect → stays connecting during grace, goes offline after graceMs', () => {
|
|
62
|
+
const { clock, monitor } = makeMonitor();
|
|
63
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
64
|
+
monitor.noteProviderStatus('p1', 'disconnected');
|
|
65
|
+
// Inside the grace window — no offline yet (transient blips don't flash UI).
|
|
66
|
+
expect(monitor.snapshot().state).toBe('connecting');
|
|
67
|
+
clock.advance(29_000);
|
|
68
|
+
expect(monitor.snapshot().state).toBe('connecting');
|
|
69
|
+
clock.advance(2_000); // total 31s > 30s grace
|
|
70
|
+
expect(monitor.snapshot().state).toBe('offline');
|
|
71
|
+
expect(monitor.snapshot().offlineSince).not.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('reconnect inside grace cancels the offline transition', () => {
|
|
75
|
+
const { clock, monitor } = makeMonitor();
|
|
76
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
77
|
+
monitor.noteProviderStatus('p1', 'disconnected');
|
|
78
|
+
clock.advance(10_000);
|
|
79
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
80
|
+
expect(monitor.snapshot().state).toBe('online');
|
|
81
|
+
clock.advance(60_000); // grace timer must have been cancelled
|
|
82
|
+
expect(monitor.snapshot().state).toBe('online');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('queues local edits while offline, resets on reconnect with a green flash', () => {
|
|
86
|
+
const { clock, monitor } = makeMonitor();
|
|
87
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
88
|
+
monitor.noteProviderStatus('p1', 'disconnected');
|
|
89
|
+
clock.advance(31_000);
|
|
90
|
+
expect(monitor.snapshot().state).toBe('offline');
|
|
91
|
+
|
|
92
|
+
monitor.noteLocalEdit();
|
|
93
|
+
monitor.noteLocalEdit();
|
|
94
|
+
monitor.noteLocalEdit();
|
|
95
|
+
expect(monitor.snapshot().queuedOps).toBe(3);
|
|
96
|
+
|
|
97
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
98
|
+
const snap = monitor.snapshot();
|
|
99
|
+
expect(snap.state).toBe('online');
|
|
100
|
+
expect(snap.queuedOps).toBe(0);
|
|
101
|
+
expect(snap.flash).toBe('synced');
|
|
102
|
+
|
|
103
|
+
clock.advance(3_500); // flashMs elapses
|
|
104
|
+
expect(monitor.snapshot().flash).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('does not count edits made while online toward the queue', () => {
|
|
108
|
+
const { monitor } = makeMonitor();
|
|
109
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
110
|
+
monitor.noteLocalEdit();
|
|
111
|
+
expect(monitor.snapshot().queuedOps).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('escalates to offline-long after escalateMs offline', () => {
|
|
115
|
+
const { clock, monitor } = makeMonitor();
|
|
116
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
117
|
+
monitor.noteProviderStatus('p1', 'disconnected');
|
|
118
|
+
clock.advance(31_000);
|
|
119
|
+
expect(monitor.snapshot().state).toBe('offline');
|
|
120
|
+
clock.advance(24 * 60 * 60 * 1000 + 1_000);
|
|
121
|
+
expect(monitor.snapshot().state).toBe('offline-long');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('aggregates multiple providers: any connected ⇒ online', () => {
|
|
125
|
+
const { clock, monitor } = makeMonitor();
|
|
126
|
+
monitor.noteProviderStatus('a', 'connected');
|
|
127
|
+
monitor.noteProviderStatus('b', 'connected');
|
|
128
|
+
// b drops, a still up → stays online (aggregate sees a connected).
|
|
129
|
+
monitor.noteProviderStatus('b', 'disconnected');
|
|
130
|
+
expect(monitor.snapshot().state).toBe('online');
|
|
131
|
+
// a drops too → grace, then offline.
|
|
132
|
+
monitor.noteProviderStatus('a', 'disconnected');
|
|
133
|
+
clock.advance(31_000);
|
|
134
|
+
expect(monitor.snapshot().state).toBe('offline');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('stop() cancels pending timers (no late transitions)', () => {
|
|
138
|
+
const { clock, changes, monitor } = makeMonitor();
|
|
139
|
+
monitor.noteProviderStatus('p1', 'connected');
|
|
140
|
+
monitor.noteProviderStatus('p1', 'disconnected');
|
|
141
|
+
monitor.stop();
|
|
142
|
+
const before = changes.length;
|
|
143
|
+
clock.advance(60_000);
|
|
144
|
+
expect(changes.length).toBe(before); // no offline transition fired
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Gap 2 — .meta.json shared-layout sync codec.
|
|
2
|
+
//
|
|
3
|
+
// Artboard layout must propagate as a file diff (one machine moves an artboard →
|
|
4
|
+
// the other's .meta.json layout converges), while each user's pan/zoom
|
|
5
|
+
// (`viewport`) and the security opt-in (`syncable`) stay strictly local. These
|
|
6
|
+
// tests pin that split: applyMetaToDoc stores only the shared subset, and
|
|
7
|
+
// mergeSharedMetaIntoLocal layers it back without trampling local-only keys.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import * as Y from 'yjs';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
META_LOCAL_KEYS,
|
|
14
|
+
applyCssToDoc,
|
|
15
|
+
applyMetaToDoc,
|
|
16
|
+
cssFromDoc,
|
|
17
|
+
mergeSharedMetaIntoLocal,
|
|
18
|
+
metaFromDoc,
|
|
19
|
+
} from '../sync/codec.ts';
|
|
20
|
+
|
|
21
|
+
describe('Gap 2 — meta codec: shared layout syncs, per-user keys stay local', () => {
|
|
22
|
+
test('applyMetaToDoc stores ONLY the shared subset (viewport/last_modified/syncable stripped)', () => {
|
|
23
|
+
const doc = new Y.Doc();
|
|
24
|
+
const full = JSON.stringify({
|
|
25
|
+
title: 'Kanban',
|
|
26
|
+
layout: { 'ab-1': { x: 10, y: 20 } },
|
|
27
|
+
viewport: { x: 999, y: 888, zoom: 0.5 },
|
|
28
|
+
last_modified: '2026-01-01T00:00:00Z',
|
|
29
|
+
syncable: true,
|
|
30
|
+
});
|
|
31
|
+
expect(applyMetaToDoc(doc, full)).toBe(true);
|
|
32
|
+
|
|
33
|
+
const shared = JSON.parse(metaFromDoc(doc) as string);
|
|
34
|
+
expect(shared.title).toBe('Kanban');
|
|
35
|
+
expect(shared.layout).toEqual({ 'ab-1': { x: 10, y: 20 } });
|
|
36
|
+
// The three per-machine keys never enter the synced doc.
|
|
37
|
+
expect(shared.viewport).toBeUndefined();
|
|
38
|
+
expect(shared.last_modified).toBeUndefined();
|
|
39
|
+
expect(shared.syncable).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('applyMetaToDoc is a NO-OP when only per-user keys differ (no churn from panning)', () => {
|
|
43
|
+
const doc = new Y.Doc();
|
|
44
|
+
applyMetaToDoc(doc, JSON.stringify({ title: 'X', viewport: { x: 1 } }));
|
|
45
|
+
// Same shared content, different viewport → must not register a change,
|
|
46
|
+
// otherwise every pan would emit a sync op.
|
|
47
|
+
expect(applyMetaToDoc(doc, JSON.stringify({ title: 'X', viewport: { x: 2 } }))).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('mergeSharedMetaIntoLocal takes shared layout, PRESERVES local viewport + syncable', () => {
|
|
51
|
+
const shared = JSON.stringify({ title: 'Kanban', layout: { 'ab-1': { x: 10, y: 20 } } });
|
|
52
|
+
const local = JSON.stringify({
|
|
53
|
+
title: 'Kanban (stale)',
|
|
54
|
+
layout: { 'ab-1': { x: 0, y: 0 } },
|
|
55
|
+
viewport: { x: 42, y: 7, zoom: 1.5 },
|
|
56
|
+
syncable: false,
|
|
57
|
+
last_modified: 'local-ts',
|
|
58
|
+
});
|
|
59
|
+
const merged = JSON.parse(mergeSharedMetaIntoLocal(local, shared) as string);
|
|
60
|
+
expect(merged.layout).toEqual({ 'ab-1': { x: 10, y: 20 } }); // shared layout won
|
|
61
|
+
expect(merged.title).toBe('Kanban'); // shared title won
|
|
62
|
+
expect(merged.viewport).toEqual({ x: 42, y: 7, zoom: 1.5 }); // LOCAL viewport kept
|
|
63
|
+
expect(merged.syncable).toBe(false); // LOCAL opt-in kept (security invariant)
|
|
64
|
+
expect(merged.last_modified).toBe('local-ts'); // local timestamp kept
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('round-trip: artboard move on A reaches B without touching B viewport / syncable', () => {
|
|
68
|
+
const aDoc = new Y.Doc();
|
|
69
|
+
applyMetaToDoc(
|
|
70
|
+
aDoc,
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
title: 'K',
|
|
73
|
+
layout: { 'ab-1': { x: 100, y: 200 } },
|
|
74
|
+
viewport: { zoom: 0.5 },
|
|
75
|
+
syncable: true,
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
const shared = metaFromDoc(aDoc) as string;
|
|
79
|
+
|
|
80
|
+
const bLocal = JSON.stringify({
|
|
81
|
+
title: 'K',
|
|
82
|
+
layout: { 'ab-1': { x: 0, y: 0 } },
|
|
83
|
+
viewport: { zoom: 2.0 },
|
|
84
|
+
syncable: false,
|
|
85
|
+
});
|
|
86
|
+
const bMerged = JSON.parse(mergeSharedMetaIntoLocal(bLocal, shared) as string);
|
|
87
|
+
expect(bMerged.layout['ab-1']).toEqual({ x: 100, y: 200 }); // A's move landed on B
|
|
88
|
+
expect(bMerged.viewport).toEqual({ zoom: 2.0 }); // B's camera untouched
|
|
89
|
+
expect(bMerged.syncable).toBe(false); // B's opt-in untouched
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a freshly-cloned peer with no local viewport just takes the shared subset', () => {
|
|
93
|
+
const merged = JSON.parse(
|
|
94
|
+
mergeSharedMetaIntoLocal('{}', JSON.stringify({ title: 'K', layout: { a: 1 } })) as string
|
|
95
|
+
);
|
|
96
|
+
expect(merged).toEqual({ title: 'K', layout: { a: 1 } });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('META_LOCAL_KEYS pins the never-synced set', () => {
|
|
100
|
+
expect([...META_LOCAL_KEYS].sort()).toEqual(['last_modified', 'syncable', 'viewport']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('mergeSharedMetaIntoLocal returns null on an unparseable shared payload', () => {
|
|
104
|
+
expect(mergeSharedMetaIntoLocal('{}', 'not json')).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Gap 3 — css codec (opaque wholesale text)', () => {
|
|
109
|
+
test('applyCssToDoc round-trips the css string', () => {
|
|
110
|
+
const doc = new Y.Doc();
|
|
111
|
+
expect(cssFromDoc(doc)).toBeNull();
|
|
112
|
+
const css = '.ka-card { color: red; }\n';
|
|
113
|
+
expect(applyCssToDoc(doc, css)).toBe(true);
|
|
114
|
+
expect(cssFromDoc(doc)).toBe(css);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('applyCssToDoc is a no-op when unchanged (loop-safe)', () => {
|
|
118
|
+
const doc = new Y.Doc();
|
|
119
|
+
applyCssToDoc(doc, '.x{}');
|
|
120
|
+
expect(applyCssToDoc(doc, '.x{}')).toBe(false);
|
|
121
|
+
expect(applyCssToDoc(doc, '.x{ color: blue }')).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|