@1agh/maude 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A connect that has not connected must never read as synced.
|
|
2
|
+
//
|
|
3
|
+
// THE FALSIFIER. Written against the pre-fix pipeline and expected to FAIL
|
|
4
|
+
// there — that failure is the evidence that the status signal is optimistic by
|
|
5
|
+
// construction, not merely rendered late. If it ever passes without the
|
|
6
|
+
// monitor changing, the premise of this whole change was wrong and the fix
|
|
7
|
+
// should be dropped rather than the assertion softened.
|
|
8
|
+
//
|
|
9
|
+
// Reported as "vyskočí modal, pak vidím jen syncing canvases, ale reálně se nic
|
|
10
|
+
// nestane": the user was told the truth about an intention and read it as a
|
|
11
|
+
// result.
|
|
12
|
+
//
|
|
13
|
+
// The two monitor-level defects it pins:
|
|
14
|
+
// 1. the monitor is BORN `online` — before a socket exists, before a token is
|
|
15
|
+
// accepted, before a byte moves. Every surface keys off that value.
|
|
16
|
+
// 2. `docs.synced` can only ever rise — a hub that dies leaves the last count
|
|
17
|
+
// frozen on screen forever, indistinguishable from a healthy one.
|
|
18
|
+
//
|
|
19
|
+
// The surface-level defects (one connected provider out of many reading green,
|
|
20
|
+
// and the status bar ignoring `docs` entirely) are pinned in
|
|
21
|
+
// `sync-presentation.test.ts`, because they live in the presentation rule
|
|
22
|
+
// rather than in the state machine.
|
|
23
|
+
|
|
24
|
+
import { describe, expect, test } from 'bun:test';
|
|
25
|
+
|
|
26
|
+
import { createConnectionMonitor, type SyncStatusSnapshot } from '../sync/connection-state.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The rule the shipped status bar applies (`app.jsx` syncSlot, pre-fix):
|
|
30
|
+
* green dot and the word "synced" whenever `state` is `online`.
|
|
31
|
+
*
|
|
32
|
+
* Reproduced verbatim rather than imported, so this test measures what a USER
|
|
33
|
+
* sees and cannot be quietly satisfied by editing the surface.
|
|
34
|
+
*/
|
|
35
|
+
function readsAsSynced(snap: SyncStatusSnapshot): boolean {
|
|
36
|
+
return snap.state === 'online' || snap.flash === 'synced';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeMonitor() {
|
|
40
|
+
const nowMs = 1_000_000;
|
|
41
|
+
const timers = new Map<number, { fireAt: number; cb: () => void }>();
|
|
42
|
+
let nextId = 1;
|
|
43
|
+
const monitor = createConnectionMonitor({
|
|
44
|
+
now: () => nowMs,
|
|
45
|
+
setTimer: (cb, ms) => {
|
|
46
|
+
const id = nextId++;
|
|
47
|
+
timers.set(id, { fireAt: nowMs + ms, cb });
|
|
48
|
+
return id as unknown as ReturnType<typeof setTimeout>;
|
|
49
|
+
},
|
|
50
|
+
clearTimer: (h) => {
|
|
51
|
+
timers.delete(h as unknown as number);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return { monitor };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('a connect that has not connected', () => {
|
|
58
|
+
test('does not read as synced at t=0', () => {
|
|
59
|
+
// The instant after Connect: the runtime started, providers exist on paper,
|
|
60
|
+
// nothing has handshaken. This is precisely the moment the user is staring
|
|
61
|
+
// at the note, and the moment the old pipeline claimed success.
|
|
62
|
+
const { monitor } = makeMonitor();
|
|
63
|
+
expect(readsAsSynced(monitor.snapshot())).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('does not read as synced while every document is still pending', () => {
|
|
67
|
+
const { monitor } = makeMonitor();
|
|
68
|
+
for (const slug of ['ui-home', 'ui-about', 'ui-welcome']) {
|
|
69
|
+
monitor.noteDocState(slug, 'pending');
|
|
70
|
+
}
|
|
71
|
+
const snap = monitor.snapshot();
|
|
72
|
+
expect(snap.docs).toEqual({ synced: 0, pending: 3, rejected: 0 });
|
|
73
|
+
expect(readsAsSynced(snap)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('does not read as synced when the hub refuses every document', () => {
|
|
77
|
+
// A rotated credential: providers reach the hub, the hub says no. Nothing
|
|
78
|
+
// is syncing, and the count that would say so must not claim otherwise.
|
|
79
|
+
const { monitor } = makeMonitor();
|
|
80
|
+
for (const slug of ['ui-home', 'ui-about']) {
|
|
81
|
+
monitor.noteDocState(slug, 'auth-rejected');
|
|
82
|
+
}
|
|
83
|
+
expect(monitor.snapshot().docs).toEqual({ synced: 0, pending: 0, rejected: 2 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('a connect that stops being connected', () => {
|
|
88
|
+
test('docs.synced falls when the providers drop', () => {
|
|
89
|
+
// Today nothing demotes a connected document, so a hub that dies leaves
|
|
90
|
+
// "3 synced" on screen indefinitely — the most convincing possible lie,
|
|
91
|
+
// because it was true a moment ago.
|
|
92
|
+
const { monitor } = makeMonitor();
|
|
93
|
+
for (const slug of ['a', 'b', 'c']) {
|
|
94
|
+
monitor.noteProviderStatus(slug, 'connected');
|
|
95
|
+
monitor.noteDocState(slug, 'connected');
|
|
96
|
+
}
|
|
97
|
+
expect(monitor.snapshot().docs?.synced).toBe(3);
|
|
98
|
+
|
|
99
|
+
for (const slug of ['a', 'b', 'c']) monitor.noteProviderStatus(slug, 'disconnected');
|
|
100
|
+
|
|
101
|
+
const snap = monitor.snapshot();
|
|
102
|
+
expect(snap.docs?.synced).toBe(0);
|
|
103
|
+
expect(snap.docs?.pending).toBe(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a refusal is not transient — a dropped provider cannot launder it', () => {
|
|
107
|
+
// `auth-rejected` outranks a disconnect: the hub gave an answer, and losing
|
|
108
|
+
// the socket does not turn that answer back into "still trying".
|
|
109
|
+
const { monitor } = makeMonitor();
|
|
110
|
+
monitor.noteDocState('a', 'auth-rejected');
|
|
111
|
+
monitor.noteProviderStatus('a', 'disconnected');
|
|
112
|
+
expect(monitor.snapshot().docs).toEqual({ synced: 0, pending: 0, rejected: 1 });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -52,12 +52,26 @@ function makeMonitor(overrides = {}) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
describe('connection monitor', () => {
|
|
55
|
-
test('starts
|
|
55
|
+
test('starts connecting, and goes online when the provider connects', () => {
|
|
56
|
+
// The seed used to be `online` — success asserted before a socket existed.
|
|
57
|
+
// See `sync-connect-honesty.test.ts` for why that mattered to a user.
|
|
56
58
|
const { monitor } = makeMonitor();
|
|
59
|
+
expect(monitor.snapshot().state).toBe('connecting');
|
|
57
60
|
monitor.noteProviderStatus('p1', 'connected');
|
|
58
61
|
expect(monitor.snapshot().state).toBe('online');
|
|
59
62
|
});
|
|
60
63
|
|
|
64
|
+
test('a link nobody answers still escalates to offline', () => {
|
|
65
|
+
// The seed change must not create a state that waits forever: a provider
|
|
66
|
+
// that only ever reports 'connecting' has to reach the offline banner on
|
|
67
|
+
// the same grace clock as a link that dropped after being live.
|
|
68
|
+
const { clock, monitor } = makeMonitor();
|
|
69
|
+
monitor.noteProviderStatus('p1', 'connecting');
|
|
70
|
+
expect(monitor.snapshot().state).toBe('connecting');
|
|
71
|
+
clock.advance(30_000);
|
|
72
|
+
expect(monitor.snapshot().state).toBe('offline');
|
|
73
|
+
});
|
|
74
|
+
|
|
61
75
|
test('disconnect → stays connecting during grace, goes offline after graceMs', () => {
|
|
62
76
|
const { clock, monitor } = makeMonitor();
|
|
63
77
|
monitor.noteProviderStatus('p1', 'connected');
|
|
@@ -212,3 +226,33 @@ describe('connection monitor — per-doc states (DDR-102)', () => {
|
|
|
212
226
|
expect(monitor.snapshot().lastSyncAt).toBe((first as number) + 10_000);
|
|
213
227
|
});
|
|
214
228
|
});
|
|
229
|
+
|
|
230
|
+
describe('what this run pulled down', () => {
|
|
231
|
+
// This field was `remoteGap`, "what the project has that this machine does
|
|
232
|
+
// not" — and it was recorded AFTER the pull, so it named exactly the
|
|
233
|
+
// canvases that had just arrived. `_sync.json` was observed listing two
|
|
234
|
+
// documents that were sitting on disk. The name now matches the fact.
|
|
235
|
+
test('records the canvases that arrived, with a true count', () => {
|
|
236
|
+
const { monitor } = makeMonitor();
|
|
237
|
+
monitor.notePulled(['ui-welcome', 'ui-how-to']);
|
|
238
|
+
expect(monitor.snapshot().pulled).toEqual({
|
|
239
|
+
names: ['ui-welcome', 'ui-how-to'],
|
|
240
|
+
count: 2,
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('a run that pulled nothing says nothing', () => {
|
|
245
|
+
const { monitor } = makeMonitor();
|
|
246
|
+
monitor.notePulled([]);
|
|
247
|
+
expect(monitor.snapshot().pulled).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('the name list is capped but the count is not — a cap must not falsify a number', () => {
|
|
251
|
+
// Hub-controlled payload size (same reasoning as rejectedSlugs).
|
|
252
|
+
const { monitor } = makeMonitor();
|
|
253
|
+
monitor.notePulled(Array.from({ length: 50 }, (_, i) => `ui-${i}`));
|
|
254
|
+
const pulled = monitor.snapshot().pulled;
|
|
255
|
+
expect(pulled?.names).toHaveLength(20);
|
|
256
|
+
expect(pulled?.count).toBe(50);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// The one rule both sync surfaces read — see sync/presentation.ts.
|
|
2
|
+
//
|
|
3
|
+
// Pins the two SURFACE defects that the monitor-level falsifier
|
|
4
|
+
// (`sync-connect-honesty.test.ts`) deliberately leaves alone:
|
|
5
|
+
// - a link with refusals reading green, because the old status bar keyed off
|
|
6
|
+
// `state` and never looked at `docs` at all;
|
|
7
|
+
// - the status bar and the connect note being able to disagree, because they
|
|
8
|
+
// were two rules over one payload.
|
|
9
|
+
//
|
|
10
|
+
// Every row of the plan's states/copy table gets a case, and each asserts the
|
|
11
|
+
// state names a NEXT ACTION where one exists — the user's explicit ask.
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import { type SyncStatusLike, syncPresentation } from '../sync/presentation.ts';
|
|
16
|
+
|
|
17
|
+
const base: SyncStatusLike = {
|
|
18
|
+
state: 'online',
|
|
19
|
+
queuedOps: 0,
|
|
20
|
+
lastSyncAt: null,
|
|
21
|
+
offlineSince: null,
|
|
22
|
+
flash: null,
|
|
23
|
+
updatedAt: 0,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const at = (over: SyncStatusLike) =>
|
|
27
|
+
syncPresentation({ ...base, ...over }, { project: 'alligators' });
|
|
28
|
+
|
|
29
|
+
describe('nothing to say', () => {
|
|
30
|
+
test('an unlinked project has no hub status', () => {
|
|
31
|
+
expect(syncPresentation({ linked: false })).toBeNull();
|
|
32
|
+
expect(syncPresentation(null)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('the states a person passes through', () => {
|
|
37
|
+
test('connecting — no document has settled yet', () => {
|
|
38
|
+
const p = at({ state: 'connecting', docs: { synced: 0, pending: 3, rejected: 0 } });
|
|
39
|
+
expect(p?.phase).toBe('connecting');
|
|
40
|
+
expect(p?.online).toBe(false);
|
|
41
|
+
expect(p?.title).toContain('Connecting to alligators');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('syncing — visible progress, with a real count', () => {
|
|
45
|
+
const p = at({ docs: { synced: 40, pending: 35, rejected: 0 } });
|
|
46
|
+
expect(p?.phase).toBe('syncing');
|
|
47
|
+
expect(p?.online).toBe(false);
|
|
48
|
+
expect(p?.label).toBe('40/75');
|
|
49
|
+
expect(p?.title).toContain('40 of 75');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('synced — and names what arrived, without telling you to open it', () => {
|
|
53
|
+
const p = at({
|
|
54
|
+
docs: { synced: 75, pending: 0, rejected: 0 },
|
|
55
|
+
pulled: { names: ['ui-welcome', 'ui-how-to'], count: 2 },
|
|
56
|
+
});
|
|
57
|
+
expect(p?.phase).toBe('synced');
|
|
58
|
+
expect(p?.online).toBe(true);
|
|
59
|
+
expect(p?.title).toContain('2 came down');
|
|
60
|
+
// Names the fact; the decision to open hub-authored TSX stays the person's.
|
|
61
|
+
expect(p?.next).toContain('new to this machine');
|
|
62
|
+
expect(p?.next).not.toContain('Open one');
|
|
63
|
+
expect(p?.names).toEqual(['ui-welcome', 'ui-how-to']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('synced with nothing pulled has no next action — because there is none', () => {
|
|
67
|
+
const p = at({ docs: { synced: 5, pending: 0, rejected: 0 } });
|
|
68
|
+
expect(p?.phase).toBe('synced');
|
|
69
|
+
expect(p?.next).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('the states that used to read green', () => {
|
|
74
|
+
test('a hub that refuses everything is not synced', () => {
|
|
75
|
+
// THE surface defect: `state` is 'online' (a socket exists, the hub is
|
|
76
|
+
// reachable) while every document's edits go nowhere. The old status bar
|
|
77
|
+
// showed a green dot and the word "synced" here.
|
|
78
|
+
const p = at({ state: 'online', docs: { synced: 0, pending: 0, rejected: 75 } });
|
|
79
|
+
expect(p?.phase).toBe('refused');
|
|
80
|
+
expect(p?.online).toBe(false);
|
|
81
|
+
expect(p?.label).toBe('75 refused');
|
|
82
|
+
expect(p?.next).toContain('Reconnect');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a partial refusal is reported, not averaged away', () => {
|
|
86
|
+
const p = at({ state: 'online', docs: { synced: 72, pending: 0, rejected: 3 } });
|
|
87
|
+
expect(p?.phase).toBe('refused');
|
|
88
|
+
expect(p?.title).toContain('3 of 75');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('an unreachable hub outranks a stale count', () => {
|
|
92
|
+
const p = at({ state: 'offline', queuedOps: 4, docs: { synced: 75, pending: 0, rejected: 0 } });
|
|
93
|
+
expect(p?.phase).toBe('offline');
|
|
94
|
+
expect(p?.online).toBe(false);
|
|
95
|
+
expect(p?.label).toBe('offline · 4 ↑');
|
|
96
|
+
expect(p?.next).toContain('resumes by itself');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('payloads that predate the counts', () => {
|
|
101
|
+
test('an old payload falls back to state without inventing document facts', () => {
|
|
102
|
+
const p = at({ state: 'online', docs: undefined });
|
|
103
|
+
expect(p?.phase).toBe('synced');
|
|
104
|
+
expect(p?.title).not.toContain('canvas');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a linked project with zero known documents reads as connecting, not synced', () => {
|
|
108
|
+
const p = at({ state: 'online', docs: { synced: 0, pending: 0, rejected: 0 } });
|
|
109
|
+
expect(p?.phase).toBe('connecting');
|
|
110
|
+
expect(p?.online).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('nothing syncable keeps its own honest copy and names the move', () => {
|
|
114
|
+
const p = syncPresentation({ notSyncable: true, tsxCount: 4, reason: 'No canvases yet.' });
|
|
115
|
+
expect(p?.phase).toBe('nothing-syncable');
|
|
116
|
+
expect(p?.label).toBe('0 syncable · 4 tsx');
|
|
117
|
+
expect(p?.next).toContain('Create a canvas');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('a payload it cannot read is not a payload that says everything is fine', () => {
|
|
122
|
+
// `/_sync-status` returns JSON.parse of `_sync.json` with NO schema, so the
|
|
123
|
+
// input here is whatever is on disk — a partial write, an older producer, a
|
|
124
|
+
// newer one. `synced` used to be the fall-through, which made the reassuring
|
|
125
|
+
// answer the only reachable one for every shape not recognised.
|
|
126
|
+
test('an empty docs object does not render as "all NaN canvases", green', () => {
|
|
127
|
+
const p = at({ state: 'online', docs: {} as never });
|
|
128
|
+
expect(p?.phase).not.toBe('synced');
|
|
129
|
+
expect(p?.online).toBe(false);
|
|
130
|
+
expect(p?.title).not.toContain('NaN');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('negative, fractional and non-numeric counts all fail closed', () => {
|
|
134
|
+
for (const docs of [
|
|
135
|
+
{ synced: 5, pending: -5, rejected: 0 },
|
|
136
|
+
{ synced: 1, pending: null, rejected: 0 },
|
|
137
|
+
{ synced: 1.5, pending: 0, rejected: 0 },
|
|
138
|
+
{ synced: '75', pending: 0, rejected: 0 },
|
|
139
|
+
] as never[]) {
|
|
140
|
+
const p = at({ state: 'online', docs });
|
|
141
|
+
expect(p?.online).toBe(false);
|
|
142
|
+
expect(p?.phase).not.toBe('synced');
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('an absent docs field is still the old-payload path, not "unreadable"', () => {
|
|
147
|
+
// Absent and malformed are different facts and must not collapse.
|
|
148
|
+
const p = at({ state: 'online', docs: undefined });
|
|
149
|
+
expect(p?.phase).toBe('synced');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a wild pulled count cannot reach the sentence', () => {
|
|
153
|
+
const p = at({
|
|
154
|
+
docs: { synced: 1, pending: 0, rejected: 0 },
|
|
155
|
+
pulled: { names: [], count: 1e9 } as never,
|
|
156
|
+
});
|
|
157
|
+
expect(p?.title).not.toContain('1000000000');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('hub-supplied text is untrusted (DDR-054)', () => {
|
|
162
|
+
test('counts come first and names are capped in number', () => {
|
|
163
|
+
const p = at({
|
|
164
|
+
state: 'online',
|
|
165
|
+
docs: { synced: 0, pending: 0, rejected: 9 },
|
|
166
|
+
rejectedSlugs: ['a', 'b', 'c', 'd', 'e'],
|
|
167
|
+
});
|
|
168
|
+
expect(p?.names).toHaveLength(3);
|
|
169
|
+
// The label a hostile name could otherwise dominate carries no name at all.
|
|
170
|
+
expect(p?.label).toBe('9 refused');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a long project name cannot run away with the sentence', () => {
|
|
174
|
+
const p = syncPresentation(
|
|
175
|
+
{ ...base, docs: { synced: 1, pending: 0, rejected: 0 } },
|
|
176
|
+
{ project: 'x'.repeat(500) }
|
|
177
|
+
);
|
|
178
|
+
expect(p?.title.length).toBeLessThan(200);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('names survive being absent or empty', () => {
|
|
182
|
+
const p = at({ state: 'online', docs: { synced: 0, pending: 0, rejected: 2 } });
|
|
183
|
+
expect(p?.names).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The gap between what a project HOLDS and what this machine carries.
|
|
2
|
+
//
|
|
3
|
+
// Regression cover for the reported "Open in Maude does nothing": the desktop
|
|
4
|
+
// synced 72/72 and was accurate about the wrong set — three canvases lived only
|
|
5
|
+
// on the hub and no local counter could ever show it.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
describeRemoteDiff,
|
|
11
|
+
diffRemoteDocs,
|
|
12
|
+
fetchRemoteDocs,
|
|
13
|
+
pullTargets,
|
|
14
|
+
slugFromDocName,
|
|
15
|
+
} from '../sync/remote-docs.ts';
|
|
16
|
+
|
|
17
|
+
const okFetch = (documents: unknown) =>
|
|
18
|
+
(async () =>
|
|
19
|
+
new Response(JSON.stringify({ documents }), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
})) as unknown as typeof fetch;
|
|
23
|
+
|
|
24
|
+
describe('fetchRemoteDocs', () => {
|
|
25
|
+
test('reads the hub listing', async () => {
|
|
26
|
+
const docs = await fetchRemoteDocs(
|
|
27
|
+
'https://hub.example',
|
|
28
|
+
't',
|
|
29
|
+
okFetch([{ name: 'ui-welcome', bytes: 2931 }])
|
|
30
|
+
);
|
|
31
|
+
expect(docs).toEqual([{ name: 'ui-welcome', bytes: 2931 }]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('carries the token as a bearer, and strips a trailing slash', async () => {
|
|
35
|
+
let seenUrl = '';
|
|
36
|
+
let seenAuth = '';
|
|
37
|
+
const spy = (async (url: string, init: RequestInit) => {
|
|
38
|
+
seenUrl = String(url);
|
|
39
|
+
seenAuth = String((init.headers as Record<string, string>).authorization);
|
|
40
|
+
return new Response(JSON.stringify({ documents: [] }), { status: 200 });
|
|
41
|
+
}) as unknown as typeof fetch;
|
|
42
|
+
await fetchRemoteDocs('https://hub.example/', 'tok123', spy);
|
|
43
|
+
expect(seenUrl).toBe('https://hub.example/api/documents');
|
|
44
|
+
expect(seenAuth).toBe('Bearer tok123');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('an unreachable or old hub yields null, never a throw', async () => {
|
|
48
|
+
// Load-bearing: sync must proceed when the listing cannot be had. A hub
|
|
49
|
+
// without the route (404) must not degrade into a failed sync.
|
|
50
|
+
const boom = (async () => {
|
|
51
|
+
throw new Error('offline');
|
|
52
|
+
}) as unknown as typeof fetch;
|
|
53
|
+
expect(await fetchRemoteDocs('https://h', 't', boom)).toBeNull();
|
|
54
|
+
|
|
55
|
+
const notFound = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
|
|
56
|
+
expect(await fetchRemoteDocs('https://h', 't', notFound)).toBeNull();
|
|
57
|
+
|
|
58
|
+
const junk = (async () =>
|
|
59
|
+
new Response(JSON.stringify({ documents: 'not-an-array' }), {
|
|
60
|
+
status: 200,
|
|
61
|
+
})) as unknown as typeof fetch;
|
|
62
|
+
expect(await fetchRemoteDocs('https://h', 't', junk)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('diffRemoteDocs', () => {
|
|
67
|
+
test('names the canvases this machine can never receive', () => {
|
|
68
|
+
// The reported case, in miniature.
|
|
69
|
+
const diff = diffRemoteDocs(
|
|
70
|
+
['ui-home', 'ui-about'],
|
|
71
|
+
[
|
|
72
|
+
{ name: 'ui-home', bytes: 1 },
|
|
73
|
+
{ name: 'ui-about', bytes: 1 },
|
|
74
|
+
{ name: 'ui-welcome', bytes: 2931 },
|
|
75
|
+
]
|
|
76
|
+
);
|
|
77
|
+
expect(diff.shared).toEqual(['ui-about', 'ui-home']);
|
|
78
|
+
expect(diff.hubOnly.map((d) => d.name)).toEqual(['ui-welcome']);
|
|
79
|
+
expect(diff.localOnly).toEqual([]);
|
|
80
|
+
expect(diff.reachable).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a canvas created here but not yet pushed is localOnly, not a gap', () => {
|
|
84
|
+
const diff = diffRemoteDocs(['ui-new'], []);
|
|
85
|
+
expect(diff.localOnly).toEqual(['ui-new']);
|
|
86
|
+
expect(diff.hubOnly).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an unreachable hub reports NOT reachable rather than a false gap', () => {
|
|
90
|
+
// Reporting every local canvas as "hub-only missing" would be worse than
|
|
91
|
+
// saying nothing — it would invent an alarm out of a network blip.
|
|
92
|
+
const diff = diffRemoteDocs(['ui-home'], null);
|
|
93
|
+
expect(diff.reachable).toBe(false);
|
|
94
|
+
expect(diff.hubOnly).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('compares WIRE names — a namespaced hub vs flat local is a real mismatch', () => {
|
|
98
|
+
// If a caller passed raw slugs against a namespaced hub, everything would
|
|
99
|
+
// look missing on both sides. The diff reports exactly that rather than
|
|
100
|
+
// papering over it, because it IS the doc-identity bug (DDR-192 §5).
|
|
101
|
+
const diff = diffRemoteDocs(['ui-home'], [{ name: 'ws/acme/main/ui-home', bytes: 1 }]);
|
|
102
|
+
expect(diff.shared).toEqual([]);
|
|
103
|
+
expect(diff.hubOnly.map((d) => d.name)).toEqual(['ws/acme/main/ui-home']);
|
|
104
|
+
expect(diff.localOnly).toEqual(['ui-home']);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('describeRemoteDiff', () => {
|
|
109
|
+
test('says what is missing, with names', () => {
|
|
110
|
+
const msg = describeRemoteDiff(
|
|
111
|
+
diffRemoteDocs(
|
|
112
|
+
[],
|
|
113
|
+
[
|
|
114
|
+
{ name: 'ui-welcome', bytes: 1 },
|
|
115
|
+
{ name: 'ui-how_to_use_maude', bytes: 1 },
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
expect(msg).toContain('2 canvases');
|
|
120
|
+
expect(msg).toContain('ui-welcome');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('stays quiet when there is nothing to report', () => {
|
|
124
|
+
expect(describeRemoteDiff(diffRemoteDocs(['a'], [{ name: 'a', bytes: 1 }]))).toBeNull();
|
|
125
|
+
expect(describeRemoteDiff(diffRemoteDocs(['a'], null))).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('pulling hub-only documents down', () => {
|
|
130
|
+
const join = (...p: string[]) => p.join('/');
|
|
131
|
+
const resolve = (p: string) => p.replace(/\/\.\//g, '/');
|
|
132
|
+
|
|
133
|
+
test('maps a flat and a namespaced doc name to the same slug', () => {
|
|
134
|
+
expect(slugFromDocName('ui-welcome')).toBe('ui-welcome');
|
|
135
|
+
expect(slugFromDocName('ws/acme/main/ui-welcome')).toBe('ui-welcome');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('lands hub-only canvases flat under the design root', () => {
|
|
139
|
+
const targets = pullTargets(
|
|
140
|
+
[
|
|
141
|
+
{ name: 'ui-welcome', bytes: 1 },
|
|
142
|
+
{ name: 'ws/acme/main/ui-home', bytes: 1 },
|
|
143
|
+
],
|
|
144
|
+
'/p/.design',
|
|
145
|
+
join,
|
|
146
|
+
resolve,
|
|
147
|
+
'/'
|
|
148
|
+
);
|
|
149
|
+
expect(targets.map((t) => t.bodyAbs)).toEqual([
|
|
150
|
+
'/p/.design/ui-welcome.tsx',
|
|
151
|
+
'/p/.design/ui-home.tsx',
|
|
152
|
+
]);
|
|
153
|
+
// The wire name is kept — the provider must open the document the HUB has,
|
|
154
|
+
// not a name re-derived from the local path.
|
|
155
|
+
expect(targets[1].docName).toBe('ws/acme/main/ui-home');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a document NAME can never write outside the design root', () => {
|
|
159
|
+
// Hub-controlled input, last point before a create. Dots are refused
|
|
160
|
+
// outright, so traversal cannot even form a candidate path.
|
|
161
|
+
const evil = [
|
|
162
|
+
{ name: '../../etc/passwd', bytes: 1 },
|
|
163
|
+
{ name: '..', bytes: 1 },
|
|
164
|
+
{ name: 'ui/../../escape', bytes: 1 },
|
|
165
|
+
{ name: 'a b', bytes: 1 },
|
|
166
|
+
{ name: '', bytes: 1 },
|
|
167
|
+
{ name: 'x'.repeat(500), bytes: 1 },
|
|
168
|
+
];
|
|
169
|
+
expect(pullTargets(evil, '/p/.design', join, resolve, '/')).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
});
|