@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.
@@ -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
- const deadline = Date.now() + 4000;
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
- const deadline = Date.now() + 5000;
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
- expect(APP).toContain('cloudManaged={!cfg.cloud && !!cloudLinkedHub?.credentialed}');
41
- expect(APP).toContain('historyOnly={!!cfg.cloud}');
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 — every assets/ dir, asset extensions only', () => {
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
- expect(SYNC).toContain('if (isPushableAssetRel(rel)) scheduleAssetSweep(');
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', () => {