@1agh/maude 0.60.7 → 1.0.3
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 +201 -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 -71
- 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 +915 -308
- 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 +71 -108
- 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 +90 -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
|
+
});
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
isPushableAssetRel,
|
|
15
15
|
listPushableAssets,
|
|
16
16
|
pushAssets,
|
|
17
|
-
pushOneAsset,
|
|
18
17
|
putTimeoutMs,
|
|
19
18
|
} from '../sync/asset-push.ts';
|
|
20
19
|
|
|
@@ -43,12 +42,22 @@ describe('listPushableAssets — classifier membership over the whole design roo
|
|
|
43
42
|
mkdirSync(join(designRoot, '_history/assets'), { recursive: true });
|
|
44
43
|
writeFileSync(join(designRoot, '_history/assets/old.png'), 'x');
|
|
45
44
|
|
|
46
|
-
|
|
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([
|
|
47
49
|
'assets/a1b2c3d4.png',
|
|
48
50
|
'system/alligators/assets/fonts/Gators-Bold.woff2',
|
|
49
51
|
'system/alligators/assets/logos/horizontal-green.svg',
|
|
50
52
|
'system/alligators/assets/photos/P1020428.JPG',
|
|
51
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
|
+
);
|
|
52
61
|
});
|
|
53
62
|
|
|
54
63
|
test('non-asset extensions and dotfiles are filtered; missing root is []', () => {
|
|
@@ -89,9 +98,10 @@ describe('listPushableAssets — classifier membership over the whole design roo
|
|
|
89
98
|
'system/ds/preview/_brand-css.ts',
|
|
90
99
|
'system/ds/preview/_layout.css',
|
|
91
100
|
]);
|
|
92
|
-
//
|
|
93
|
-
// media under an assets/ dir, so none of them move.
|
|
94
|
-
|
|
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([]);
|
|
95
105
|
});
|
|
96
106
|
|
|
97
107
|
test('declared canvasGroups decide what a canvas body IS', () => {
|
|
@@ -832,137 +842,90 @@ describe('isPushableAssetRel — the on-change trigger', () => {
|
|
|
832
842
|
// is the half that was missing. A behavioural test needs a live runtime plus a
|
|
833
843
|
// 1.5 s debounce and destabilises `sync-runtime.test.ts`'s shared fixtures, so
|
|
834
844
|
// the invariant is pinned where it is cheap and unambiguous instead.
|
|
835
|
-
describe('the runtime
|
|
845
|
+
describe('the runtime schedules the legacy push on change, not only at boot', () => {
|
|
836
846
|
const SYNC = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');
|
|
837
847
|
|
|
838
|
-
test('the fs watcher decides with isPushableAssetRel and schedules a
|
|
848
|
+
test('the fs watcher decides with isPushableAssetRel and schedules a pass', () => {
|
|
839
849
|
// The predicate gets the project's OWN canvas groups — with the default
|
|
840
850
|
// set a custom-group project's shared module would answer canvas-owned
|
|
841
|
-
// and silently never schedule the
|
|
842
|
-
// 2026-08-16 latency fix the same gate also feeds the fast lane; the
|
|
843
|
-
// sweep stays the reconciler.)
|
|
851
|
+
// and silently never schedule the pass that uploads it.
|
|
844
852
|
expect(SYNC).toContain('if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) {');
|
|
845
|
-
expect(SYNC).toContain('
|
|
853
|
+
expect(SYNC).toContain('scheduleLegacyPush(linkedHub.url);');
|
|
846
854
|
});
|
|
847
855
|
|
|
848
|
-
test('bursts coalesce, and a change DURING a
|
|
849
|
-
// Dragging six images on is six events; each
|
|
850
|
-
// wire. And a file written while a
|
|
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.
|
|
851
859
|
expect(SYNC).toMatch(/ASSET_SWEEP_DEBOUNCE_MS = [\d_]+/);
|
|
852
|
-
expect(SYNC).toContain('
|
|
860
|
+
expect(SYNC).toContain('legacyPushAgain');
|
|
853
861
|
});
|
|
854
862
|
|
|
855
|
-
test('
|
|
856
|
-
|
|
857
|
-
|
|
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
|
+
);
|
|
871
|
+
expect(SYNC).toContain(
|
|
872
|
+
'if (legacyLane === false) return; // the plane owns pushes on this hub'
|
|
873
|
+
);
|
|
858
874
|
});
|
|
859
|
-
});
|
|
860
875
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
mkdirSync(join(designRoot, 'assets'), { recursive: true });
|
|
865
|
-
writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'fresh sticker');
|
|
866
|
-
|
|
867
|
-
const calls: Array<{ method: string; url: string }> = [];
|
|
868
|
-
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
869
|
-
const url = String(input);
|
|
870
|
-
const method = init?.method ?? 'GET';
|
|
871
|
-
calls.push({ method, url });
|
|
872
|
-
if (url.endsWith('/_asset-probe')) return Response.json({ present: [] });
|
|
873
|
-
return new Response('{"ok":true}', { status: 200 });
|
|
874
|
-
}) as typeof fetch;
|
|
875
|
-
|
|
876
|
-
const r = await pushOneAsset({
|
|
877
|
-
designRoot,
|
|
878
|
-
rel: 'assets/aaaaaaaa.png',
|
|
879
|
-
hubUrl: 'https://alligators.cloud.maude.sh/',
|
|
880
|
-
token: () => 'mau_tok',
|
|
881
|
-
fetchImpl,
|
|
882
|
-
});
|
|
883
|
-
expect(r).toEqual({ ok: true });
|
|
884
|
-
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
|
|
885
|
-
'POST https://alligators.cloud.maude.sh/_asset-probe',
|
|
886
|
-
'PUT https://alligators.cloud.maude.sh/assets/aaaaaaaa.png',
|
|
887
|
-
]);
|
|
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;');
|
|
888
879
|
});
|
|
880
|
+
});
|
|
889
881
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
// too — without this skip the lane would upload the hub's own bytes back.
|
|
882
|
+
describe('pushAssets — the cancel hook (the Sync panel button, legacy window)', () => {
|
|
883
|
+
test('cancelled() abandons the rest of the pass', async () => {
|
|
893
884
|
const designRoot = scratchDesignRoot();
|
|
894
885
|
mkdirSync(join(designRoot, 'assets'), { recursive: true });
|
|
895
|
-
writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), '
|
|
886
|
+
writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'a');
|
|
887
|
+
writeFileSync(join(designRoot, 'assets/bbbbbbbb.png'), 'b');
|
|
888
|
+
writeFileSync(join(designRoot, 'assets/cccccccc.png'), 'c');
|
|
896
889
|
|
|
897
890
|
let puts = 0;
|
|
898
891
|
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
899
|
-
const url = String(input);
|
|
900
|
-
if (url.endsWith('/_asset-probe')) return Response.json({ present: ['assets/aaaaaaaa.png'] });
|
|
901
892
|
if (init?.method === 'PUT') puts += 1;
|
|
902
|
-
return new Response('
|
|
903
|
-
}) as typeof fetch;
|
|
893
|
+
return new Response('not found', { status: 404 });
|
|
894
|
+
}) as unknown as typeof fetch;
|
|
904
895
|
|
|
905
|
-
const
|
|
896
|
+
const out = await pushAssets({
|
|
906
897
|
designRoot,
|
|
907
|
-
rel: 'assets/aaaaaaaa.png',
|
|
908
|
-
hubUrl: 'https://alligators.cloud.maude.sh',
|
|
909
|
-
token: () => 'mau_tok',
|
|
910
|
-
fetchImpl,
|
|
911
|
-
});
|
|
912
|
-
expect(r).toEqual({ ok: true, skipped: true });
|
|
913
|
-
expect(puts).toBe(0);
|
|
914
|
-
});
|
|
915
|
-
|
|
916
|
-
test('DS-tree asset routes to /_asset-file/; non-pushable rels refuse; failures report', async () => {
|
|
917
|
-
const designRoot = scratchDesignRoot();
|
|
918
|
-
mkdirSync(join(designRoot, 'system/ds/assets'), { recursive: true });
|
|
919
|
-
writeFileSync(join(designRoot, 'system/ds/assets/mark.svg'), 'brand');
|
|
920
|
-
|
|
921
|
-
const urls: string[] = [];
|
|
922
|
-
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
923
|
-
const url = String(input);
|
|
924
|
-
if (url.endsWith('/_asset-probe')) return Response.json({ present: [] });
|
|
925
|
-
if (init?.method === 'PUT') {
|
|
926
|
-
urls.push(url);
|
|
927
|
-
return new Response('refused', { status: 403 });
|
|
928
|
-
}
|
|
929
|
-
return new Response(null, { status: 404 });
|
|
930
|
-
}) as typeof fetch;
|
|
931
|
-
|
|
932
|
-
const r = await pushOneAsset({
|
|
933
|
-
designRoot,
|
|
934
|
-
rel: 'system/ds/assets/mark.svg',
|
|
935
898
|
hubUrl: 'https://hub.example.com',
|
|
936
899
|
token: () => 't',
|
|
937
900
|
fetchImpl,
|
|
901
|
+
log: { log: () => {}, warn: () => {} },
|
|
902
|
+
// Cancel after the first upload settles.
|
|
903
|
+
cancelled: () => puts >= 1,
|
|
938
904
|
});
|
|
939
|
-
expect(
|
|
940
|
-
|
|
941
|
-
|
|
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
|
+
});
|
|
942
911
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
});
|
|
951
|
-
|
|
952
|
-
|
|
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']);
|
|
953
926
|
});
|
|
954
927
|
|
|
955
|
-
test('
|
|
956
|
-
|
|
957
|
-
expect(SYNC2).toContain('queueFastPush(rel');
|
|
958
|
-
expect(SYNC2).toMatch(/REFERENCE_FILE_RE\.test\(rel\)/);
|
|
959
|
-
expect(SYNC2).toContain('requestFastPull');
|
|
960
|
-
// stop() tears the lanes down.
|
|
961
|
-
expect(SYNC2).toContain('for (const t of fastPushTimers.values()) clearTimeout(t);');
|
|
962
|
-
expect(SYNC2).toContain('requestFastPull = null;');
|
|
963
|
-
// The cell never pushes — same guard as the sweep.
|
|
964
|
-
expect(SYNC2).toMatch(/if \(stopped \|\| cellPairing\) return; \/\/ the cell never pushes/);
|
|
928
|
+
test('`linkedHub.syncFiles: false` is the per-project rollback and still works', () => {
|
|
929
|
+
expect(listPushableAssets(designRoot(), { syncFiles: false })).toEqual([]);
|
|
965
930
|
});
|
|
966
931
|
});
|
|
967
|
-
|
|
968
|
-
const SYNC2 = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');
|