@1agh/maude 0.60.3 → 0.60.4
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/api.ts +14 -0
- package/apps/studio/client/app.jsx +43 -9
- package/apps/studio/client/panels/SyncPanel.jsx +27 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +707 -707
- package/apps/studio/http.ts +12 -1
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +111 -98
- package/apps/studio/sync/file-membership.ts +290 -0
- package/apps/studio/sync/file-pull.ts +330 -0
- package/apps/studio/sync/index.ts +198 -10
- package/apps/studio/sync/remote-docs.ts +98 -4
- package/apps/studio/sync/status.ts +25 -0
- package/apps/studio/sync/tombstone-apply.ts +131 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/exporters/jobs.test.ts +10 -4
- package/apps/studio/test/git-cloud-posture.test.ts +11 -2
- package/apps/studio/test/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +52 -2
- package/apps/studio/test/sync-file-membership.test.ts +331 -0
- package/apps/studio/test/sync-file-pull.test.ts +333 -0
- package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/package.json +8 -8
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// DDR-218's withdrawal, applied to EVERY surface that reports a save state.
|
|
2
|
+
//
|
|
3
|
+
// The decision withdrew the working-tree half of the Changes surface whenever
|
|
4
|
+
// somebody else commits this project — a cloud cell (`historyOnly`, `cfg.cloud`)
|
|
5
|
+
// or the cell behind a linked+credentialed desktop repo (`cloudManaged`). It
|
|
6
|
+
// landed in GitPanel only. `app.jsx` went on computing the dirty count a second
|
|
7
|
+
// time, ungated, and handing it to the toolbar menu and the status-bar chip, so
|
|
8
|
+
// both shells kept rendering "N unsaved" — the exact claim the withdrawal exists
|
|
9
|
+
// to delete. Reported again 2026-08-14 ("89 unsaved changes on the cloud, 36 on
|
|
10
|
+
// the desktop"), which is what these assertions are here to stop.
|
|
11
|
+
//
|
|
12
|
+
// Source-level (sync-panel-surface style): what they pin is what would regress —
|
|
13
|
+
// someone re-deriving the posture at a call site, or adding a THIRD surface that
|
|
14
|
+
// reads `gitStatus.files.length` directly.
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const STUDIO = join(import.meta.dir, '..');
|
|
21
|
+
const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
|
|
22
|
+
const PANEL = readFileSync(join(STUDIO, 'client', 'panels', 'GitPanel.jsx'), 'utf8');
|
|
23
|
+
|
|
24
|
+
describe('one expression owns "who is saving this project"', () => {
|
|
25
|
+
test('both postures are named once, at the top level', () => {
|
|
26
|
+
expect(APP).toContain('const cellManaged = !!cfg.cloud;');
|
|
27
|
+
expect(APP).toContain('const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;');
|
|
28
|
+
expect(APP).toContain('const savingIsManaged = cellManaged || cloudManaged;');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('the GitPanel call site consumes those names rather than re-deriving them', () => {
|
|
32
|
+
// Re-deriving is how the two halves drift apart in the first place.
|
|
33
|
+
expect(APP).toContain('historyOnly={cellManaged}');
|
|
34
|
+
expect(APP).toContain('cloudManaged={cloudManaged}');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('the dirty COUNT is withheld wherever saving is managed', () => {
|
|
39
|
+
test('unsavedCount is gated, not raw', () => {
|
|
40
|
+
expect(APP).toContain(
|
|
41
|
+
'const unsavedCount = savingIsManaged ? 0 : gitStatus?.files?.length || 0;'
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('no surface reads the raw file count for display', () => {
|
|
46
|
+
// Exactly one `gitStatus?.files?.length` may exist, and it is the gated one
|
|
47
|
+
// above. A second occurrence is a new leak by construction.
|
|
48
|
+
const raw = APP.match(/gitStatus\?\.files\?\.length/g) ?? [];
|
|
49
|
+
expect(raw.length).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('every changesCount consumer is fed the gated value', () => {
|
|
53
|
+
const feeds = APP.match(/changesCount=\{[^}]+\}/g) ?? [];
|
|
54
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
55
|
+
for (const feed of feeds) expect(feed).toBe('changesCount={unsavedCount}');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('the status-bar chip names the mechanism instead of a count', () => {
|
|
60
|
+
test('it receives the posture', () => {
|
|
61
|
+
expect(APP).toContain('savingIsManaged={savingIsManaged}');
|
|
62
|
+
expect(APP).toMatch(/savingIsManaged = false,/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('managed saving short-circuits the whole unsaved / to-publish ladder', () => {
|
|
66
|
+
expect(APP).toMatch(/\{savingIsManaged\s*\?/);
|
|
67
|
+
expect(APP).toContain("'cloud saving'");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('"N to publish" is withheld too — Publish is withdrawn in that posture', () => {
|
|
71
|
+
// A gated count alone would fall through to the unpushed branch, which is
|
|
72
|
+
// the same local-git offer wearing a different label.
|
|
73
|
+
expect(APP).toContain('unpushed={savingIsManaged ? 0 : gitStatus?.unpushed || 0}');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('the panel half of DDR-218 is still intact', () => {
|
|
78
|
+
test('withdrawal covers both postures', () => {
|
|
79
|
+
expect(PANEL).toContain('const withdrawn = historyOnly || cloudManaged;');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the panel badge stays gated on it', () => {
|
|
83
|
+
expect(PANEL).toMatch(/!withdrawn && count > 0/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the cloud-managed note keeps its testid and names the live mechanism', () => {
|
|
87
|
+
expect(PANEL).toContain('data-testid="git-cloud-managed"');
|
|
88
|
+
expect(PANEL).toMatch(/Cloud is saving/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -53,7 +53,11 @@ describe('exporters/jobs — concurrency gate', () => {
|
|
|
53
53
|
let sawThirdQueued = false;
|
|
54
54
|
let maxRunningObserved = 0;
|
|
55
55
|
let allDone = false;
|
|
56
|
-
|
|
56
|
+
// Generous: the assertion below is "the queue drains at all", not "it
|
|
57
|
+
// drains fast". The budget only has to outlast DEFLATE over the seeded
|
|
58
|
+
// sandbox on the slowest machine we run on — a budget tuned to one
|
|
59
|
+
// machine's disk speed fails there while the queue is working fine.
|
|
60
|
+
const deadline = Date.now() + 20_000;
|
|
57
61
|
while (Date.now() < deadline && !allDone) {
|
|
58
62
|
const jobs = await listJobs(port);
|
|
59
63
|
const byId = new Map(jobs.map((j) => [j.id, j]));
|
|
@@ -74,7 +78,7 @@ describe('exporters/jobs — concurrency gate', () => {
|
|
|
74
78
|
} finally {
|
|
75
79
|
await killProc(proc);
|
|
76
80
|
}
|
|
77
|
-
});
|
|
81
|
+
}, 30_000);
|
|
78
82
|
});
|
|
79
83
|
|
|
80
84
|
describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)', () => {
|
|
@@ -109,7 +113,9 @@ describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)',
|
|
|
109
113
|
// Once jobs drain below the cap, enqueue works again — this is a
|
|
110
114
|
// capacity limit, not a permanent lockout.
|
|
111
115
|
let drained = false;
|
|
112
|
-
|
|
116
|
+
// Same reasoning as the concurrency gate above — budget for the slowest
|
|
117
|
+
// machine, since what's under test is that the cap lifts, not latency.
|
|
118
|
+
const deadline = Date.now() + 20_000;
|
|
113
119
|
while (Date.now() < deadline && !drained) {
|
|
114
120
|
const jobs = await listJobs(port);
|
|
115
121
|
drained = jobs.every((j) => j.status === 'done' || j.status === 'failed');
|
|
@@ -121,7 +127,7 @@ describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)',
|
|
|
121
127
|
} finally {
|
|
122
128
|
await killProc(proc);
|
|
123
129
|
}
|
|
124
|
-
});
|
|
130
|
+
}, 30_000);
|
|
125
131
|
});
|
|
126
132
|
|
|
127
133
|
describe('exporters/jobs — list() + history under concurrency', () => {
|
|
@@ -37,8 +37,17 @@ describe('a cloud-linked repo shows ONE save mechanism (DDR-218)', () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
test('the app gate is the CORROBORATED link, and the cell posture is untouched', () => {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Hoisted to a single top-level definition (2026-08-14) so the toolbar and
|
|
41
|
+
// status bar read the SAME posture the panel does — see
|
|
42
|
+
// cloud-managed-save-surfaces.test.ts. The gate itself is unchanged, and
|
|
43
|
+
// `credentialed` is the load-bearing half: config.json alone is
|
|
44
|
+
// attacker-authorable (B2), so a link claim without corroboration must
|
|
45
|
+
// never reach this posture.
|
|
46
|
+
expect(APP).toContain('const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;');
|
|
47
|
+
expect(APP).toContain('const cellManaged = !!cfg.cloud;');
|
|
48
|
+
// …and the panel is fed from those names, not a re-derivation.
|
|
49
|
+
expect(APP).toContain('cloudManaged={cloudManaged}');
|
|
50
|
+
expect(APP).toContain('historyOnly={cellManaged}');
|
|
42
51
|
});
|
|
43
52
|
|
|
44
53
|
test('CloudBar lifts every link change the posture depends on', () => {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The downward asset lane — see sync/asset-pull.ts.
|
|
2
|
+
//
|
|
3
|
+
// Regression cover for the reported "obrázky v annotations se nevykreslí":
|
|
4
|
+
// `<slug>.annotations.svg` synced, its `assets/<sha8>.png` did not, and the
|
|
5
|
+
// canvas rendered a broken-image glyph on every peer but the one that made it.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { pullAssets, referencedAssets } from '../sync/asset-pull.ts';
|
|
13
|
+
|
|
14
|
+
function project(files: Record<string, string>): string {
|
|
15
|
+
const root = mkdtempSync(path.join(tmpdir(), 'maude-asset-pull-'));
|
|
16
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
17
|
+
const abs = path.join(root, rel);
|
|
18
|
+
mkdirSync(path.dirname(abs), { recursive: true });
|
|
19
|
+
writeFileSync(abs, body);
|
|
20
|
+
}
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const annotation = (name: string) =>
|
|
25
|
+
`<svg xmlns="http://www.w3.org/2000/svg"><image data-tool="image" href="assets/${name}"/></svg>`;
|
|
26
|
+
|
|
27
|
+
/** A hub that serves every asset as three bytes. */
|
|
28
|
+
const servingFetch = (seen: string[] = []) =>
|
|
29
|
+
(async (url: string) => {
|
|
30
|
+
seen.push(String(url));
|
|
31
|
+
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
|
32
|
+
}) as unknown as typeof fetch;
|
|
33
|
+
|
|
34
|
+
describe('referencedAssets', () => {
|
|
35
|
+
test('finds the image an annotation references', () => {
|
|
36
|
+
const root = project({ 'ui-board.annotations.svg': annotation('c0fa9c7f.png') });
|
|
37
|
+
expect(referencedAssets(root)).toEqual(['c0fa9c7f.png']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('reads canvas bodies and sidecars too, deduplicating', () => {
|
|
41
|
+
const root = project({
|
|
42
|
+
'ui-board.annotations.svg': annotation('a.png'),
|
|
43
|
+
'ui/card.tsx': `<img src="assets/b.jpg" /><img src="assets/a.png" />`,
|
|
44
|
+
'ui/card.css': `.x { background: url(assets/c.webp) }`,
|
|
45
|
+
});
|
|
46
|
+
expect(referencedAssets(root)).toEqual(['a.png', 'b.jpg', 'c.webp']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('refuses a reference that could place a file outside assets/', () => {
|
|
50
|
+
// An annotation can itself have arrived from the hub, so a name found in
|
|
51
|
+
// one is untrusted input.
|
|
52
|
+
const root = project({
|
|
53
|
+
'ui-board.annotations.svg': [
|
|
54
|
+
'<svg>',
|
|
55
|
+
'<image href="assets/../../etc/passwd"/>',
|
|
56
|
+
'<image href="assets/ok.png"/>',
|
|
57
|
+
'</svg>',
|
|
58
|
+
].join(''),
|
|
59
|
+
});
|
|
60
|
+
expect(referencedAssets(root)).toEqual(['ok.png']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('refuses an extension outside the asset allowlist', () => {
|
|
64
|
+
const root = project({
|
|
65
|
+
'ui/card.tsx': `"assets/payload.sh" "assets/x.tsx" "assets/real.png"`,
|
|
66
|
+
});
|
|
67
|
+
expect(referencedAssets(root)).toEqual(['real.png']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('ignores runtime directories — a trashed canvas wants nothing', () => {
|
|
71
|
+
const root = project({
|
|
72
|
+
'_trash/old-deleted-1/old.annotations.svg': annotation('ghost.png'),
|
|
73
|
+
'_history/ui-board/1.tsx': `"assets/older.png"`,
|
|
74
|
+
'ui-board.annotations.svg': annotation('live.png'),
|
|
75
|
+
});
|
|
76
|
+
expect(referencedAssets(root)).toEqual(['live.png']);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('pullAssets', () => {
|
|
81
|
+
const opts = (root: string, fetchImpl: typeof fetch) => ({
|
|
82
|
+
designRoot: root,
|
|
83
|
+
hubUrl: 'https://hub.example/',
|
|
84
|
+
token: () => 'tok',
|
|
85
|
+
fetchImpl,
|
|
86
|
+
log: { log: () => {}, warn: () => {} },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('fetches a referenced asset this machine does not have', async () => {
|
|
90
|
+
const root = project({ 'ui-board.annotations.svg': annotation('c0fa9c7f.png') });
|
|
91
|
+
const seen: string[] = [];
|
|
92
|
+
const res = await pullAssets(opts(root, servingFetch(seen)));
|
|
93
|
+
|
|
94
|
+
expect(res.pulled).toEqual(['c0fa9c7f.png']);
|
|
95
|
+
expect(seen).toEqual(['https://hub.example/assets/c0fa9c7f.png']);
|
|
96
|
+
expect(readFileSync(path.join(root, 'assets/c0fa9c7f.png'))).toEqual(Buffer.from([1, 2, 3]));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('asks for nothing when every reference is already on disk', async () => {
|
|
100
|
+
const root = project({
|
|
101
|
+
'ui-board.annotations.svg': annotation('have.png'),
|
|
102
|
+
'assets/have.png': 'bytes',
|
|
103
|
+
});
|
|
104
|
+
let called = 0;
|
|
105
|
+
const counting = (async () => {
|
|
106
|
+
called += 1;
|
|
107
|
+
return new Response('x', { status: 200 });
|
|
108
|
+
}) as unknown as typeof fetch;
|
|
109
|
+
|
|
110
|
+
const res = await pullAssets(opts(root, counting));
|
|
111
|
+
expect(called).toBe(0);
|
|
112
|
+
expect(res.present).toBe(1);
|
|
113
|
+
expect(res.pulled).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('is idempotent — a second pass costs no requests', async () => {
|
|
117
|
+
const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
|
|
118
|
+
await pullAssets(opts(root, servingFetch()));
|
|
119
|
+
const seen: string[] = [];
|
|
120
|
+
const second = await pullAssets(opts(root, servingFetch(seen)));
|
|
121
|
+
expect(seen).toEqual([]);
|
|
122
|
+
expect(second.pulled).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a 404 is recorded and retried, never fatal', async () => {
|
|
126
|
+
// Ordinary: the peer that owns the bytes has not pushed them yet.
|
|
127
|
+
const root = project({ 'ui-board.annotations.svg': annotation('later.png') });
|
|
128
|
+
const missing = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
|
|
129
|
+
const res = await pullAssets(opts(root, missing));
|
|
130
|
+
expect(res.pulled).toEqual([]);
|
|
131
|
+
expect(res.failed).toEqual([{ name: 'later.png', reason: 'HTTP 404' }]);
|
|
132
|
+
expect(existsSync(path.join(root, 'assets/later.png'))).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a throwing hub is not fatal either', async () => {
|
|
136
|
+
const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
|
|
137
|
+
const boom = (async () => {
|
|
138
|
+
throw new Error('offline');
|
|
139
|
+
}) as unknown as typeof fetch;
|
|
140
|
+
const res = await pullAssets(opts(root, boom));
|
|
141
|
+
expect(res.pulled).toEqual([]);
|
|
142
|
+
expect(res.failed[0]?.reason).toBe('offline');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('refuses an empty body rather than writing a zero-byte image', async () => {
|
|
146
|
+
const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
|
|
147
|
+
const empty = (async () =>
|
|
148
|
+
new Response(new Uint8Array([]), { status: 200 })) as unknown as typeof fetch;
|
|
149
|
+
const res = await pullAssets(opts(root, empty));
|
|
150
|
+
expect(res.pulled).toEqual([]);
|
|
151
|
+
expect(existsSync(path.join(root, 'assets/x.png'))).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('leaves no .part file behind on success', async () => {
|
|
155
|
+
// The write is temp-then-rename so the dev server serving this very path
|
|
156
|
+
// never reads a half-written image.
|
|
157
|
+
const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
|
|
158
|
+
await pullAssets(opts(root, servingFetch()));
|
|
159
|
+
expect(existsSync(path.join(root, 'assets/x.png.part'))).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -21,7 +21,7 @@ function scratchDesignRoot(): string {
|
|
|
21
21
|
return mkdtempSync(join(tmpdir(), 'asset-push-'));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
describe('listPushableAssets —
|
|
24
|
+
describe('listPushableAssets — classifier membership over the whole design root', () => {
|
|
25
25
|
test('finds top-level content-addressed AND nested DS/brand assets', () => {
|
|
26
26
|
const designRoot = scratchDesignRoot();
|
|
27
27
|
// Class 1 — top-level content-addressed uploads (+ a .photo.json sidecar).
|
|
@@ -67,6 +67,51 @@ describe('listPushableAssets — every assets/ dir, asset extensions only', () =
|
|
|
67
67
|
writeFileSync(join(designRoot, 'assets/a/b/c/d/e/f/g/x.png'), 'x');
|
|
68
68
|
expect(listPushableAssets(designRoot)).toEqual([]);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
test('the file-plane widening: the RCA miss-list goes up, canvas lanes stay home', () => {
|
|
72
|
+
// feature-sync-file-plane — the sweep now carries what the fresh-link RCA
|
|
73
|
+
// named laneless: token stylesheets, docs, shared modules, underscore
|
|
74
|
+
// FILES. A canvas body and its named sidecars stay the CRDT lanes'.
|
|
75
|
+
const designRoot = scratchDesignRoot();
|
|
76
|
+
mkdirSync(join(designRoot, 'system/ds/preview'), { recursive: true });
|
|
77
|
+
writeFileSync(join(designRoot, 'config.json'), '{}');
|
|
78
|
+
writeFileSync(join(designRoot, 'system/ds/brand.css'), 'x');
|
|
79
|
+
writeFileSync(join(designRoot, 'system/ds/README.md'), 'x');
|
|
80
|
+
writeFileSync(join(designRoot, 'system/ds/preview/_brand-css.ts'), 'x');
|
|
81
|
+
writeFileSync(join(designRoot, 'system/ds/preview/_layout.css'), 'x');
|
|
82
|
+
writeFileSync(join(designRoot, 'system/ds/preview/specimen.tsx'), 'x');
|
|
83
|
+
writeFileSync(join(designRoot, 'system/ds/preview/specimen.css'), 'x'); // sibling css lane
|
|
84
|
+
writeFileSync(join(designRoot, 'system/ds/preview/specimen.meta.json'), '{}');
|
|
85
|
+
expect(listPushableAssets(designRoot, { syncFiles: true })).toEqual([
|
|
86
|
+
'system/ds/README.md',
|
|
87
|
+
'system/ds/brand.css',
|
|
88
|
+
'system/ds/preview/_brand-css.ts',
|
|
89
|
+
'system/ds/preview/_layout.css',
|
|
90
|
+
]);
|
|
91
|
+
// Flag OFF ⇒ today's reach, byte-for-byte: none of these are binary
|
|
92
|
+
// media under an assets/ dir, so none of them move.
|
|
93
|
+
expect(listPushableAssets(designRoot)).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('declared canvasGroups decide what a canvas body IS', () => {
|
|
97
|
+
const designRoot = scratchDesignRoot();
|
|
98
|
+
mkdirSync(join(designRoot, 'mocks'), { recursive: true });
|
|
99
|
+
mkdirSync(join(designRoot, 'lib'), { recursive: true });
|
|
100
|
+
writeFileSync(join(designRoot, 'mocks/screen.tsx'), 'x');
|
|
101
|
+
writeFileSync(join(designRoot, 'lib/helpers.tsx'), 'x');
|
|
102
|
+
const groups = [{ path: 'mocks' }];
|
|
103
|
+
// `mocks/screen.tsx` is a canvas here; `lib/helpers.tsx` a shared module.
|
|
104
|
+
expect(listPushableAssets(designRoot, { canvasGroups: groups, syncFiles: true })).toEqual([
|
|
105
|
+
'lib/helpers.tsx',
|
|
106
|
+
]);
|
|
107
|
+
// …and both the groups and the flag reach the walk through config.json —
|
|
108
|
+
// the out-of-process worker's path.
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(designRoot, 'config.json'),
|
|
111
|
+
JSON.stringify({ canvasGroups: groups, linkedHub: { url: 'http://h', syncFiles: true } })
|
|
112
|
+
);
|
|
113
|
+
expect(listPushableAssets(designRoot)).toEqual(['lib/helpers.tsx']);
|
|
114
|
+
});
|
|
70
115
|
});
|
|
71
116
|
|
|
72
117
|
describe('pushAssets — routes each class to the right hub endpoint', () => {
|
|
@@ -790,7 +835,12 @@ describe('the runtime sweeps on change, not only at boot', () => {
|
|
|
790
835
|
const SYNC = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');
|
|
791
836
|
|
|
792
837
|
test('the fs watcher decides with isPushableAssetRel and schedules a sweep', () => {
|
|
793
|
-
|
|
838
|
+
// The predicate gets the project's OWN canvas groups — with the default
|
|
839
|
+
// set a custom-group project's shared module would answer canvas-owned
|
|
840
|
+
// and silently never schedule the sweep that uploads it.
|
|
841
|
+
expect(SYNC).toContain(
|
|
842
|
+
'if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) scheduleAssetSweep('
|
|
843
|
+
);
|
|
794
844
|
});
|
|
795
845
|
|
|
796
846
|
test('bursts coalesce, and a change DURING a sweep is not lost', () => {
|