@1agh/maude 0.60.6 → 1.0.2
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/apps/studio/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +190 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -6
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +919 -214
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +90 -16
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +99 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// `designRel` and the rels built on it, validated before they reach git —
|
|
2
|
+
// F-13 / B12 of the v1.0.0 gate set.
|
|
3
|
+
//
|
|
4
|
+
// The finding was NOT "argv injection": every `runGit` / `run` call that carries
|
|
5
|
+
// a dynamic path already puts it after a `--` separator, so a leading `-` is a
|
|
6
|
+
// filename to git rather than a flag. The finding is CONTAINMENT, which `--`
|
|
7
|
+
// does nothing about. A rel carrying `..` is a perfectly well-formed pathspec
|
|
8
|
+
// that names a file outside the design root — and the autocommit path feeds
|
|
9
|
+
// exactly such rels into `git add -- <paths>` inside a workspace cell, with no
|
|
10
|
+
// human at the keyboard to notice `.github/workflows/release.yml` acquiring a
|
|
11
|
+
// commit.
|
|
12
|
+
//
|
|
13
|
+
// So the shapes below are the hostile ones, and the two consumers that matter
|
|
14
|
+
// are pinned: the containment PREFIX (which must never silently degrade to
|
|
15
|
+
// "no filter") and the staging BATCH (which must drop the poisoned rel and keep
|
|
16
|
+
// committing everybody else's work).
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
|
|
20
|
+
import { isSafeGitRel, partitionSafeGitRels, safeGitPrefix } from '../git/safe-rel.ts';
|
|
21
|
+
|
|
22
|
+
const HOSTILE: [string, string][] = [
|
|
23
|
+
['../escape', 'parent traversal — the whole point'],
|
|
24
|
+
['a/../../b', 'traversal in the middle, where a prefix check would not see it'],
|
|
25
|
+
['..', 'bare parent'],
|
|
26
|
+
['.', 'bare current — a pathspec meaning "everything"'],
|
|
27
|
+
['a/./b', 'a no-op segment that normalises differently in two places'],
|
|
28
|
+
['/etc/passwd', 'absolute'],
|
|
29
|
+
['C:/Windows/System32', 'Windows-absolute'],
|
|
30
|
+
['a\\..\\b', 'backslash separators — traversal that survives a forward-slash check'],
|
|
31
|
+
['--upload-pack=/bin/sh', 'the flag-shaped rel the `--` separator exists for'],
|
|
32
|
+
['-rf', 'a shorter one'],
|
|
33
|
+
['a\u0000/../../b', 'NUL — truncates at the syscall boundary, so it is two paths'],
|
|
34
|
+
['a\u001f/b', 'other control characters'],
|
|
35
|
+
['a /b', 'trailing space in a segment — invisible in every UI that shows it'],
|
|
36
|
+
['', 'empty'],
|
|
37
|
+
[`${'x/'.repeat(300)}y`, 'past the length cap'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const BENIGN = [
|
|
41
|
+
'.design/ui/screen.tsx',
|
|
42
|
+
'.design/system/ds/README.md',
|
|
43
|
+
'.design/assets/a1b2c3d4.png',
|
|
44
|
+
'assets/logo.svg',
|
|
45
|
+
'.design/preview/_layout.css',
|
|
46
|
+
'.design/ui/My Canvas.tsx',
|
|
47
|
+
'.design/ui/-not-leading.tsx'.replace('/-not', '/not'),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
describe('isSafeGitRel', () => {
|
|
51
|
+
for (const [rel, why] of HOSTILE) {
|
|
52
|
+
test(`refuses ${JSON.stringify(rel)} — ${why}`, () => {
|
|
53
|
+
expect(isSafeGitRel(rel)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const rel of BENIGN) {
|
|
58
|
+
test(`admits ${JSON.stringify(rel)}`, () => {
|
|
59
|
+
expect(isSafeGitRel(rel)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('a non-string is refused rather than coerced', () => {
|
|
64
|
+
for (const v of [null, undefined, 42, {}, ['a']]) {
|
|
65
|
+
expect(isSafeGitRel(v)).toBe(false);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('safeGitPrefix — an absent prefix and a REFUSED one are different answers', () => {
|
|
71
|
+
test('absent is the empty prefix (no containment filter), and that is legitimate', () => {
|
|
72
|
+
expect(safeGitPrefix(undefined)).toBe('');
|
|
73
|
+
expect(safeGitPrefix('')).toBe('');
|
|
74
|
+
expect(safeGitPrefix(null)).toBe('');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a normal design root normalises', () => {
|
|
78
|
+
expect(safeGitPrefix('.design')).toBe('.design');
|
|
79
|
+
expect(safeGitPrefix('/.design/')).toBe('.design');
|
|
80
|
+
expect(safeGitPrefix('design\\root')).toBe('design/root');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a hostile prefix is null — NOT the empty string', () => {
|
|
84
|
+
// This distinction is the whole reason the function returns `string | null`.
|
|
85
|
+
// Empty means "match everything", so a caller that mapped a refusal onto ''
|
|
86
|
+
// would answer a traversal attempt by WIDENING the scope to the whole
|
|
87
|
+
// repository. The caller (`normPrefix`) falls back to '.design' instead.
|
|
88
|
+
//
|
|
89
|
+
// A LEADING SLASH IS NOT HOSTILE HERE, and this is the one place the prefix
|
|
90
|
+
// reading differs from the pathspec reading. `context.ts` already strips
|
|
91
|
+
// `^/+` off `cfg.designRoot`, i.e. a configured `/.design` has always meant
|
|
92
|
+
// "`.design`, relative to the repo root" — so `/etc/passwd` reads as the
|
|
93
|
+
// prefix `etc/passwd`, which names a directory that does not exist and
|
|
94
|
+
// therefore matches nothing. Harmless as a CONTAINMENT FILTER; still
|
|
95
|
+
// refused by `isSafeGitRel` where it would be used as a PATHSPEC.
|
|
96
|
+
const rootRelative = new Set(['/etc/passwd']);
|
|
97
|
+
for (const [rel] of HOSTILE) {
|
|
98
|
+
if (rel === '') continue; // '' is the absent case, tested above
|
|
99
|
+
if (rootRelative.has(rel)) {
|
|
100
|
+
expect(safeGitPrefix(rel)).toBe('etc/passwd');
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
expect(safeGitPrefix(rel)).toBeNull();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('partitionSafeGitRels — one poisoned rel must not wedge the batch', () => {
|
|
109
|
+
test('splits a mixed batch, keeping the order of each side', () => {
|
|
110
|
+
const { safe, refused } = partitionSafeGitRels([
|
|
111
|
+
'.design/ui/a.tsx',
|
|
112
|
+
'../../.github/workflows/release.yml',
|
|
113
|
+
'.design/ui/b.tsx',
|
|
114
|
+
'-rf',
|
|
115
|
+
'.design/system/ds/README.md',
|
|
116
|
+
]);
|
|
117
|
+
expect(safe).toEqual(['.design/ui/a.tsx', '.design/ui/b.tsx', '.design/system/ds/README.md']);
|
|
118
|
+
expect(refused).toEqual(['../../.github/workflows/release.yml', '-rf']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('an all-clean batch is unchanged', () => {
|
|
122
|
+
const { safe, refused } = partitionSafeGitRels(BENIGN);
|
|
123
|
+
expect(safe).toEqual(BENIGN);
|
|
124
|
+
expect(refused).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('an all-hostile batch stages nothing rather than throwing', () => {
|
|
128
|
+
const { safe, refused } = partitionSafeGitRels(['../a', '/b', '..']);
|
|
129
|
+
expect(safe).toEqual([]);
|
|
130
|
+
expect(refused).toHaveLength(3);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -4,6 +4,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
4
4
|
|
|
5
5
|
import { type Context, createBus } from '../context.ts';
|
|
6
6
|
import {
|
|
7
|
+
classifyChange,
|
|
7
8
|
createContainerWriteBridge,
|
|
8
9
|
createHmrBroadcaster,
|
|
9
10
|
HMR_DEBOUNCE_MS,
|
|
@@ -208,3 +209,28 @@ describe('container write bridge — synthesises fs:any the container fs.watch m
|
|
|
208
209
|
expect(got).toHaveLength(0);
|
|
209
210
|
});
|
|
210
211
|
});
|
|
212
|
+
|
|
213
|
+
describe('the PhotoEdit sidecar reaches open canvases', () => {
|
|
214
|
+
// `assets/<sha8>.photo.json` changing on disk is a peer's photo adjustment
|
|
215
|
+
// arriving. It used to classify as null — no message, no re-bake, and the
|
|
216
|
+
// edit appeared only after a manual reload.
|
|
217
|
+
const noSibling = () => false;
|
|
218
|
+
|
|
219
|
+
test('a photo sidecar classifies as an asset heal', () => {
|
|
220
|
+
const msg = classifyChange('assets/3631ac58.photo.json', noSibling);
|
|
221
|
+
expect(msg?.mode).toBe('asset');
|
|
222
|
+
expect(msg?.file).toBe('assets/3631ac58.photo.json');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a DS-tree photo sidecar too', () => {
|
|
226
|
+
expect(classifyChange('system/brand/assets/aabbccdd.photo.json', noSibling)?.mode).toBe(
|
|
227
|
+
'asset'
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('an ordinary json outside assets/ still classifies as before', () => {
|
|
232
|
+
// .meta.json keeps its dedicated lane; other json (module-ish) unchanged.
|
|
233
|
+
expect(classifyChange('ui/home.meta.json', noSibling)?.mode).toBe('meta');
|
|
234
|
+
expect(classifyChange('notes/data.photo.json', noSibling)).toBe(null);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// peer-selection-follows-camera — the collaboration counterpart of the
|
|
2
|
+
// annotation-resize-handle fix (9ba40b96). A peer's selection frame is measured
|
|
3
|
+
// off the LOCAL DOM every render, so it only tracks a pan/zoom if the component
|
|
4
|
+
// actually re-renders while the camera moves. Both halos are `memo`, and through
|
|
5
|
+
// a gesture the peer's awareness is unchanged — so without a viewport prop that
|
|
6
|
+
// ticks with the camera, memo bails out and the frame sits frozen in screen
|
|
7
|
+
// space while the artboard it outlines slides away underneath it.
|
|
8
|
+
//
|
|
9
|
+
// Needs a live DOM (memo bailout + a stubbed getBoundingClientRect) — register
|
|
10
|
+
// happy-dom + drive a createRoot, same convention as mode-toggle.test.tsx.
|
|
11
|
+
|
|
12
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
GlobalRegistrator.register();
|
|
17
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
|
+
});
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
GlobalRegistrator.unregister();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
import { act, type ReactNode } from 'react';
|
|
24
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
25
|
+
import { PeerAnnotationSelection, PeerSelection } from '../cursors-overlay.tsx';
|
|
26
|
+
import type { ForeignAwareness } from '../use-collab.tsx';
|
|
27
|
+
|
|
28
|
+
// The camera the stubbed measurements project through — stands in for the world
|
|
29
|
+
// plane's CSS transform, which is what really moves the target under a pan.
|
|
30
|
+
let camera = { x: 0, y: 0, zoom: 1 };
|
|
31
|
+
|
|
32
|
+
/** A target element whose screen rect follows `camera`, like real canvas content. */
|
|
33
|
+
function target(
|
|
34
|
+
attr: string,
|
|
35
|
+
value: string,
|
|
36
|
+
world: { x: number; y: number; w: number; h: number }
|
|
37
|
+
) {
|
|
38
|
+
const el = document.createElement('div');
|
|
39
|
+
el.setAttribute(attr, value);
|
|
40
|
+
el.getBoundingClientRect = () =>
|
|
41
|
+
({
|
|
42
|
+
left: world.x * camera.zoom + camera.x,
|
|
43
|
+
top: world.y * camera.zoom + camera.y,
|
|
44
|
+
width: world.w * camera.zoom,
|
|
45
|
+
height: world.h * camera.zoom,
|
|
46
|
+
}) as DOMRect;
|
|
47
|
+
document.body.appendChild(el);
|
|
48
|
+
return el;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let root: Root | null = null;
|
|
52
|
+
let host: HTMLElement | null = null;
|
|
53
|
+
|
|
54
|
+
function mount(ui: ReactNode): void {
|
|
55
|
+
host = document.createElement('div');
|
|
56
|
+
document.body.appendChild(host);
|
|
57
|
+
root = createRoot(host);
|
|
58
|
+
act(() => {
|
|
59
|
+
root?.render(ui);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rerender(ui: ReactNode): void {
|
|
64
|
+
act(() => {
|
|
65
|
+
root?.render(ui);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function frameTransform(): string {
|
|
70
|
+
const el = host?.querySelector('.dc-peer-selection') as HTMLElement | null;
|
|
71
|
+
return el?.style.transform ?? '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
act(() => {
|
|
76
|
+
root?.unmount();
|
|
77
|
+
});
|
|
78
|
+
host?.remove();
|
|
79
|
+
root = null;
|
|
80
|
+
host = null;
|
|
81
|
+
document.body.innerHTML = '';
|
|
82
|
+
camera = { x: 0, y: 0, zoom: 1 };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// One stable object across the whole test — a real pan/zoom does not touch the
|
|
86
|
+
// peer's awareness, and that identity is exactly what makes memo bail out.
|
|
87
|
+
const peer = {
|
|
88
|
+
clientID: 7,
|
|
89
|
+
name: 'you@local.test',
|
|
90
|
+
color: 'oklch(56% 0.17 50)',
|
|
91
|
+
cursor: null,
|
|
92
|
+
selection: { cssPath: '[data-cd-id="hero"]' },
|
|
93
|
+
annotationSelection: ['stroke-1'],
|
|
94
|
+
} as unknown as ForeignAwareness;
|
|
95
|
+
|
|
96
|
+
describe('cursors-overlay / PeerSelection', () => {
|
|
97
|
+
test('the frame follows a pan', () => {
|
|
98
|
+
target('data-cd-id', 'hero', { x: 100, y: 60, w: 200, h: 120 });
|
|
99
|
+
mount(<PeerSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
|
|
100
|
+
expect(frameTransform()).toBe('translate(100px, 60px)');
|
|
101
|
+
|
|
102
|
+
camera = { x: -40, y: -25, zoom: 1 };
|
|
103
|
+
rerender(<PeerSelection peer={peer} viewport={camera} />);
|
|
104
|
+
expect(frameTransform()).toBe('translate(60px, 35px)');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the frame follows a zoom (position AND size)', () => {
|
|
108
|
+
target('data-cd-id', 'hero', { x: 100, y: 60, w: 200, h: 120 });
|
|
109
|
+
mount(<PeerSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
|
|
110
|
+
|
|
111
|
+
camera = { x: 0, y: 0, zoom: 2 };
|
|
112
|
+
rerender(<PeerSelection peer={peer} viewport={camera} />);
|
|
113
|
+
const el = host?.querySelector('.dc-peer-selection') as HTMLElement;
|
|
114
|
+
expect(el.style.transform).toBe('translate(200px, 120px)');
|
|
115
|
+
expect(el.style.width).toBe('400px');
|
|
116
|
+
expect(el.style.height).toBe('240px');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('cursors-overlay / PeerAnnotationSelection', () => {
|
|
121
|
+
test('the stroke halo follows a pan', () => {
|
|
122
|
+
target('data-id', 'stroke-1', { x: 100, y: 60, w: 200, h: 120 });
|
|
123
|
+
mount(<PeerAnnotationSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
|
|
124
|
+
// Padded 3px so the halo sits OUTSIDE the stroke.
|
|
125
|
+
expect(frameTransform()).toBe('translate(97px, 57px)');
|
|
126
|
+
|
|
127
|
+
camera = { x: -40, y: -25, zoom: 1 };
|
|
128
|
+
rerender(<PeerAnnotationSelection peer={peer} viewport={camera} />);
|
|
129
|
+
expect(frameTransform()).toBe('translate(57px, 32px)');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -240,8 +240,11 @@ describe('Task 1 — the workspace-mode guard opens only for loopback + commit-d
|
|
|
240
240
|
);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
-
test('refuses when shared-doc is off — pairing IS the single-doc model', () => {
|
|
244
|
-
|
|
243
|
+
test('refuses when shared-doc is explicitly off — pairing IS the single-doc model', () => {
|
|
244
|
+
// Increment 7 flipped the default ON, so an UNSET flag now pairs; only the
|
|
245
|
+
// explicit opt-out (the rollback flip) refuses.
|
|
246
|
+
expect(resolveCellPairing(pairingEnv({ MAUDE_SHARED_DOC: undefined })).pairing).not.toBeNull();
|
|
247
|
+
expect(resolveCellPairing(pairingEnv({ MAUDE_SHARED_DOC: '0' })).refusal).toBe(
|
|
245
248
|
'shared-doc-off'
|
|
246
249
|
);
|
|
247
250
|
});
|
|
@@ -580,3 +580,81 @@ describe('CanvasSyncAgent — fail-closed on snapshot failure (DDR-102 F1)', ()
|
|
|
580
580
|
expect(conflicts[0].snapshotFailed).toBeUndefined();
|
|
581
581
|
});
|
|
582
582
|
});
|
|
583
|
+
|
|
584
|
+
describe('cold-start meta — an empty doc is not a canvas with no title', () => {
|
|
585
|
+
// A canvas created on a PEER used to reach the hub as a body with no meta:
|
|
586
|
+
// no title, no kind, no design-system binding. It stayed that way until
|
|
587
|
+
// somebody moved an artboard, because a meta EDIT was the only thing that
|
|
588
|
+
// ever pushed meta into the doc.
|
|
589
|
+
//
|
|
590
|
+
// The cloud hid it. A cell's studio child arms `activity:suppress` on create
|
|
591
|
+
// and the container write bridge turns that into an `fs:any` a quarter-second
|
|
592
|
+
// later — by which time the new canvas has an agent to receive it. A desktop
|
|
593
|
+
// has no bridge: its real `fs.watch` fires immediately, before the agent
|
|
594
|
+
// exists, and the event lands nowhere. One side won the race, the other lost
|
|
595
|
+
// it, and the gap underneath (cold-start meta was doc→file only) was
|
|
596
|
+
// invisible from the winning end.
|
|
597
|
+
|
|
598
|
+
const metaPaths = () => ({ ...paths(), meta: join(dir, 'screen.meta.json') });
|
|
599
|
+
|
|
600
|
+
function metaAgent(extra: Record<string, unknown> = {}): CanvasSyncAgent {
|
|
601
|
+
const a = createCanvasSyncAgent({
|
|
602
|
+
slug: 'screen',
|
|
603
|
+
doc: docB,
|
|
604
|
+
paths: metaPaths(),
|
|
605
|
+
echoGuard: createEchoGuard(),
|
|
606
|
+
flushMs: 0,
|
|
607
|
+
...extra,
|
|
608
|
+
});
|
|
609
|
+
a.start();
|
|
610
|
+
return a;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
test('local meta seeds the doc when the doc has none', async () => {
|
|
614
|
+
writeFileSync(
|
|
615
|
+
metaPaths().meta,
|
|
616
|
+
JSON.stringify({ title: 'Kanban', kind: 'web', designSystem: 'project' })
|
|
617
|
+
);
|
|
618
|
+
agent = metaAgent();
|
|
619
|
+
await agent.reconcile();
|
|
620
|
+
|
|
621
|
+
// The other peer must be able to SEE it — that is the whole point.
|
|
622
|
+
const seen = JSON.parse(docA.getText('meta').toString());
|
|
623
|
+
expect(seen.title).toBe('Kanban');
|
|
624
|
+
expect(seen.kind).toBe('web');
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('it does NOT overwrite meta the doc already carries', async () => {
|
|
628
|
+
// The safety property. Seeding runs only into a vacuum, so it can never be
|
|
629
|
+
// a peer quietly winning a disagreement about a shared key.
|
|
630
|
+
docA.getText('meta').insert(0, JSON.stringify({ title: 'From the hub', kind: 'web' }));
|
|
631
|
+
writeFileSync(metaPaths().meta, JSON.stringify({ title: 'From my disk', kind: 'mobile' }));
|
|
632
|
+
|
|
633
|
+
agent = metaAgent();
|
|
634
|
+
await agent.reconcile();
|
|
635
|
+
|
|
636
|
+
expect(JSON.parse(docA.getText('meta').toString()).title).toBe('From the hub');
|
|
637
|
+
// …and the hub's opinion lands on disk, which is the pre-existing behaviour.
|
|
638
|
+
expect(JSON.parse(readFileSync(metaPaths().meta, 'utf8')).title).toBe('From the hub');
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('no local meta and no doc meta is simply nothing to do', async () => {
|
|
642
|
+
agent = metaAgent();
|
|
643
|
+
await agent.reconcile();
|
|
644
|
+
expect(docA.getText('meta').toString()).toBe('');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test('per-user keys never leave the machine', async () => {
|
|
648
|
+
// `viewport` is the per-user camera (DDR-115). Seeding must go through the
|
|
649
|
+
// same shared-subset filter as every other meta write, or the fix would
|
|
650
|
+
// start syncing somebody's scroll position.
|
|
651
|
+
writeFileSync(
|
|
652
|
+
metaPaths().meta,
|
|
653
|
+
JSON.stringify({ title: 'Kanban', viewport: { x: 12, y: 34, zoom: 2 } })
|
|
654
|
+
);
|
|
655
|
+
agent = metaAgent();
|
|
656
|
+
await agent.reconcile();
|
|
657
|
+
|
|
658
|
+
expect(docA.getText('meta').toString()).not.toContain('viewport');
|
|
659
|
+
});
|
|
660
|
+
});
|
|
@@ -42,12 +42,22 @@ describe('listPushableAssets — classifier membership over the whole design roo
|
|
|
42
42
|
mkdirSync(join(designRoot, '_history/assets'), { recursive: true });
|
|
43
43
|
writeFileSync(join(designRoot, '_history/assets/old.png'), 'x');
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// `syncFiles: false` is now stated rather than assumed: the flag defaults
|
|
46
|
+
// ON from Increment 4, so a test that meant "the narrow media sweep" has
|
|
47
|
+
// to say so. What it pins is unchanged.
|
|
48
|
+
expect(listPushableAssets(designRoot, { syncFiles: false })).toEqual([
|
|
46
49
|
'assets/a1b2c3d4.png',
|
|
47
50
|
'system/alligators/assets/fonts/Gators-Bold.woff2',
|
|
48
51
|
'system/alligators/assets/logos/horizontal-green.svg',
|
|
49
52
|
'system/alligators/assets/photos/P1020428.JPG',
|
|
50
53
|
]);
|
|
54
|
+
|
|
55
|
+
// With the plane on, the photo sidecar travels too — it is the
|
|
56
|
+
// non-destructive edit state for that image, and an image whose edits stay
|
|
57
|
+
// behind arrives looking wrong rather than arriving late.
|
|
58
|
+
expect(listPushableAssets(designRoot, { syncFiles: true })).toContain(
|
|
59
|
+
'assets/a1b2c3d4.photo.json'
|
|
60
|
+
);
|
|
51
61
|
});
|
|
52
62
|
|
|
53
63
|
test('non-asset extensions and dotfiles are filtered; missing root is []', () => {
|
|
@@ -88,9 +98,10 @@ describe('listPushableAssets — classifier membership over the whole design roo
|
|
|
88
98
|
'system/ds/preview/_brand-css.ts',
|
|
89
99
|
'system/ds/preview/_layout.css',
|
|
90
100
|
]);
|
|
91
|
-
//
|
|
92
|
-
// media under an assets/ dir, so none of them move.
|
|
93
|
-
|
|
101
|
+
// Explicitly OFF ⇒ the pre-Sync-v2 reach, byte-for-byte: none of these are
|
|
102
|
+
// binary media under an assets/ dir, so none of them move. This is the
|
|
103
|
+
// documented per-project rollback, so it keeps its own test.
|
|
104
|
+
expect(listPushableAssets(designRoot, { syncFiles: false })).toEqual([]);
|
|
94
105
|
});
|
|
95
106
|
|
|
96
107
|
test('declared canvasGroups decide what a canvas body IS', () => {
|
|
@@ -831,27 +842,90 @@ describe('isPushableAssetRel — the on-change trigger', () => {
|
|
|
831
842
|
// is the half that was missing. A behavioural test needs a live runtime plus a
|
|
832
843
|
// 1.5 s debounce and destabilises `sync-runtime.test.ts`'s shared fixtures, so
|
|
833
844
|
// the invariant is pinned where it is cheap and unambiguous instead.
|
|
834
|
-
describe('the runtime
|
|
845
|
+
describe('the runtime schedules the legacy push on change, not only at boot', () => {
|
|
835
846
|
const SYNC = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');
|
|
836
847
|
|
|
837
|
-
test('the fs watcher decides with isPushableAssetRel and schedules a
|
|
848
|
+
test('the fs watcher decides with isPushableAssetRel and schedules a pass', () => {
|
|
838
849
|
// The predicate gets the project's OWN canvas groups — with the default
|
|
839
850
|
// set a custom-group project's shared module would answer canvas-owned
|
|
840
|
-
// and silently never schedule the
|
|
851
|
+
// and silently never schedule the pass that uploads it.
|
|
852
|
+
expect(SYNC).toContain('if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) {');
|
|
853
|
+
expect(SYNC).toContain('scheduleLegacyPush(linkedHub.url);');
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test('bursts coalesce, and a change DURING a pass is not lost', () => {
|
|
857
|
+
// Dragging six images on is six events; each pass costs a probe over the
|
|
858
|
+
// wire. And a file written while a pass runs was not in that pass's list.
|
|
859
|
+
expect(SYNC).toMatch(/ASSET_SWEEP_DEBOUNCE_MS = [\d_]+/);
|
|
860
|
+
expect(SYNC).toContain('legacyPushAgain');
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test('the lane is decided once by the capability probe, and a journal hub never sees the legacy client', () => {
|
|
864
|
+
// Sync v2 Increment 5 — the burn-down's one invariant: exactly ONE push
|
|
865
|
+
// lane per boot. A journal hub gets the plane; only a journal-less hub
|
|
866
|
+
// (or an unreachable/opted-out probe) gets the legacy client.
|
|
867
|
+
expect(SYNC).toContain('decidePushLane(filePlane === null);');
|
|
868
|
+
expect(SYNC).toMatch(
|
|
869
|
+
/if \(stopped \|\| cellPairing \|\| legacyLane !== true\) return; \/\/ the cell never pushes/
|
|
870
|
+
);
|
|
841
871
|
expect(SYNC).toContain(
|
|
842
|
-
'if (
|
|
872
|
+
'if (legacyLane === false) return; // the plane owns pushes on this hub'
|
|
843
873
|
);
|
|
844
874
|
});
|
|
845
875
|
|
|
846
|
-
test('
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
876
|
+
test('stop() clears the pending pass and cancels a running one', () => {
|
|
877
|
+
expect(SYNC).toContain('if (legacyPushTimer !== null) clearTimeout(legacyPushTimer);');
|
|
878
|
+
expect(SYNC).toContain('legacyPushCancel = true;');
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe('pushAssets — the cancel hook (the Sync panel button, legacy window)', () => {
|
|
883
|
+
test('cancelled() abandons the rest of the pass', async () => {
|
|
884
|
+
const designRoot = scratchDesignRoot();
|
|
885
|
+
mkdirSync(join(designRoot, 'assets'), { recursive: true });
|
|
886
|
+
writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'a');
|
|
887
|
+
writeFileSync(join(designRoot, 'assets/bbbbbbbb.png'), 'b');
|
|
888
|
+
writeFileSync(join(designRoot, 'assets/cccccccc.png'), 'c');
|
|
889
|
+
|
|
890
|
+
let puts = 0;
|
|
891
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
892
|
+
if (init?.method === 'PUT') puts += 1;
|
|
893
|
+
return new Response('not found', { status: 404 });
|
|
894
|
+
}) as unknown as typeof fetch;
|
|
895
|
+
|
|
896
|
+
const out = await pushAssets({
|
|
897
|
+
designRoot,
|
|
898
|
+
hubUrl: 'https://hub.example.com',
|
|
899
|
+
token: () => 't',
|
|
900
|
+
fetchImpl,
|
|
901
|
+
log: { log: () => {}, warn: () => {} },
|
|
902
|
+
// Cancel after the first upload settles.
|
|
903
|
+
cancelled: () => puts >= 1,
|
|
904
|
+
});
|
|
905
|
+
expect(puts).toBe(1);
|
|
906
|
+
// The rest of the set was neither pushed nor reported failed — it is the
|
|
907
|
+
// next pass's work, exactly like a budget wall.
|
|
908
|
+
expect(out.pushed.length + out.failed.length + out.skipped).toBeLessThan(3);
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
describe('the syncFiles default — Increment 4 flipped it ON', () => {
|
|
913
|
+
// The flip is one boolean in two places (`sync/index.ts` and here), and a
|
|
914
|
+
// drift between them means the sweep and the plane disagree about which
|
|
915
|
+
// files exist — the jurisdiction overlap Sync v2 exists to end. So the
|
|
916
|
+
// default is asserted, not assumed.
|
|
917
|
+
const designRoot = () => {
|
|
918
|
+
const root = scratchDesignRoot();
|
|
919
|
+
mkdirSync(join(root, 'system/ds'), { recursive: true });
|
|
920
|
+
writeFileSync(join(root, 'system/ds/brand.css'), 'x');
|
|
921
|
+
return root;
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
test('absent config ⇒ the plane is on', () => {
|
|
925
|
+
expect(listPushableAssets(designRoot())).toEqual(['system/ds/brand.css']);
|
|
851
926
|
});
|
|
852
927
|
|
|
853
|
-
test('
|
|
854
|
-
expect(
|
|
855
|
-
expect(SYNC).toContain('if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);');
|
|
928
|
+
test('`linkedHub.syncFiles: false` is the per-project rollback and still works', () => {
|
|
929
|
+
expect(listPushableAssets(designRoot(), { syncFiles: false })).toEqual([]);
|
|
856
930
|
});
|
|
857
931
|
});
|
|
@@ -37,6 +37,15 @@ const git: GitRunner = (args, { cwd }) =>
|
|
|
37
37
|
child.stderr.on('data', (d) => {
|
|
38
38
|
stderr += d;
|
|
39
39
|
});
|
|
40
|
+
// SPAWN FAILURE IS A RESULT, NOT A CRASH — mirroring the production runner
|
|
41
|
+
// (`git/service.ts`, which resolves 127 on 'error'). Without a listener the
|
|
42
|
+
// 'error' event is unhandled, and autocommit is DEBOUNCED: a commit queued
|
|
43
|
+
// by a finished test can fire after its temp repo is gone, so the late
|
|
44
|
+
// spawn reports `ENOENT ... posix_spawn 'git'` with no test to attribute it
|
|
45
|
+
// to. bun then prints "Unhandled error between tests" and exits 1 on a run
|
|
46
|
+
// with 844 pass / 0 fail — which is exactly how the required sync-lane gate
|
|
47
|
+
// came back red on a suite that had nothing wrong with it.
|
|
48
|
+
child.on('error', (e) => resolve({ code: 127, stdout, stderr: String(e) }));
|
|
40
49
|
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
41
50
|
});
|
|
42
51
|
|
|
@@ -455,3 +464,74 @@ describe('a contended lock must not lose the queue or the process (Phase 27 D2)'
|
|
|
455
464
|
}
|
|
456
465
|
}, 30_000);
|
|
457
466
|
});
|
|
467
|
+
|
|
468
|
+
describe('a vanished path must not wedge the agent forever', () => {
|
|
469
|
+
// THE BUG: `git add -- <path that is gone and was never tracked>` exits 128
|
|
470
|
+
// with "did not match any files". The batch failed, re-queued ITSELF
|
|
471
|
+
// INCLUDING that path, and failed again identically — so the first canvas
|
|
472
|
+
// anybody deleted stopped the cell committing ANYTHING for the life of the
|
|
473
|
+
// process, while /health went on answering 200.
|
|
474
|
+
//
|
|
475
|
+
// Observed on a local cell: five commits, then twenty-plus identical
|
|
476
|
+
// `git add failed` lines and thirty canvases sitting untracked.
|
|
477
|
+
//
|
|
478
|
+
// Paths are noted from a read taken up to debounceMs earlier, for files whose
|
|
479
|
+
// lifetime this process does not own. A path disappearing mid-window is
|
|
480
|
+
// ROUTINE, so it has to be a case rather than an error.
|
|
481
|
+
|
|
482
|
+
const GONE = '.design/ui/Deleted.tsx';
|
|
483
|
+
|
|
484
|
+
test('a never-tracked path that vanished is dropped, and the rest still commits', async () => {
|
|
485
|
+
const auto = createAutoCommit({ repoRoot: repo, run: git, debounceMs: 5, log: silent() });
|
|
486
|
+
write(REL, 'export default () => "edited";\n');
|
|
487
|
+
auto.note(REL, ALICE);
|
|
488
|
+
// A sibling that was created and deleted inside the quiescence window.
|
|
489
|
+
auto.note(GONE, ALICE);
|
|
490
|
+
await auto.flush();
|
|
491
|
+
|
|
492
|
+
expect(await log('%s')).toEqual(['design: update Screen', 'seed']);
|
|
493
|
+
auto.stop();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test('and the agent is NOT wedged — the next edit commits normally', async () => {
|
|
497
|
+
const auto = createAutoCommit({ repoRoot: repo, run: git, debounceMs: 5, log: silent() });
|
|
498
|
+
write(REL, 'v1\n');
|
|
499
|
+
auto.note(REL, ALICE);
|
|
500
|
+
auto.note(GONE, ALICE);
|
|
501
|
+
await auto.flush();
|
|
502
|
+
|
|
503
|
+
write(REL, 'v2\n');
|
|
504
|
+
auto.note(REL, BOB);
|
|
505
|
+
await auto.flush();
|
|
506
|
+
|
|
507
|
+
// Two commits after the seed. Before the fix the first flush poisoned
|
|
508
|
+
// `touched` and every later flush failed on the same missing pathspec.
|
|
509
|
+
expect((await log('%s')).length).toBe(3);
|
|
510
|
+
auto.stop();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('a TRACKED path that vanished is committed as a deletion, not dropped', async () => {
|
|
514
|
+
// The other kind of gone. Dropping this one would let the checkout and its
|
|
515
|
+
// history diverge permanently: the file is gone from disk and git would go
|
|
516
|
+
// on believing it exists.
|
|
517
|
+
const auto = createAutoCommit({ repoRoot: repo, run: git, debounceMs: 5, log: silent() });
|
|
518
|
+
rmSync(path.join(repo, REL));
|
|
519
|
+
auto.note(REL, ALICE);
|
|
520
|
+
await auto.flush();
|
|
521
|
+
|
|
522
|
+
const tracked = (await git(['ls-files'], { cwd: repo })).stdout;
|
|
523
|
+
expect(tracked).not.toContain('Screen.tsx');
|
|
524
|
+
expect((await log('%s')).length).toBe(2);
|
|
525
|
+
auto.stop();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('a batch of nothing but vanished untracked paths is a no-op, not a failure', async () => {
|
|
529
|
+
const auto = createAutoCommit({ repoRoot: repo, run: git, debounceMs: 5, log: silent() });
|
|
530
|
+
auto.note(GONE, ALICE);
|
|
531
|
+
auto.note('.design/ui/AlsoGone.tsx', ALICE);
|
|
532
|
+
await auto.flush();
|
|
533
|
+
|
|
534
|
+
expect(await log('%s')).toEqual(['seed']);
|
|
535
|
+
auto.stop();
|
|
536
|
+
});
|
|
537
|
+
});
|