@1agh/maude 0.40.0 → 0.42.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/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -44
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1476 -76
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +65 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +19 -26
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +368 -50
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/server.ts +3 -1
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
computeAlign,
|
|
7
|
+
computeDistribute,
|
|
8
|
+
computePairGap,
|
|
9
|
+
computeTidyGrid,
|
|
10
|
+
detectEqualSpacing,
|
|
11
|
+
type KeyedRect,
|
|
12
|
+
} from '../equal-spacing-detector.ts';
|
|
6
13
|
import type { Rect } from '../use-snap-guides.tsx';
|
|
7
14
|
|
|
8
15
|
const r = (x: number, y: number, w = 100, h = 60): Rect => ({ x, y, w, h });
|
|
16
|
+
const kr = (key: string, x: number, y: number, w = 100, h = 60): KeyedRect => ({
|
|
17
|
+
key,
|
|
18
|
+
x,
|
|
19
|
+
y,
|
|
20
|
+
w,
|
|
21
|
+
h,
|
|
22
|
+
});
|
|
9
23
|
|
|
10
24
|
describe('detectEqualSpacing / preconditions', () => {
|
|
11
25
|
test('fewer than 3 rects → null', () => {
|
|
@@ -91,3 +105,153 @@ describe('detectEqualSpacing / 4+ rects', () => {
|
|
|
91
105
|
expect(out).toBeNull();
|
|
92
106
|
});
|
|
93
107
|
});
|
|
108
|
+
|
|
109
|
+
// Task L7 — Alt-hover measurement pairwise gap.
|
|
110
|
+
|
|
111
|
+
describe('computePairGap', () => {
|
|
112
|
+
test('measures the x-axis gap regardless of argument order', () => {
|
|
113
|
+
const a = r(0, 0, 100, 50);
|
|
114
|
+
const b = r(150, 20, 80, 50);
|
|
115
|
+
const fwd = computePairGap(a, b, 'x');
|
|
116
|
+
const rev = computePairGap(b, a, 'x');
|
|
117
|
+
expect(fwd).toEqual({ axis: 'x', gap: 50, from: 100, cross: 35 });
|
|
118
|
+
expect(rev).toEqual(fwd);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('measures the y-axis gap', () => {
|
|
122
|
+
const a = r(0, 0, 50, 60);
|
|
123
|
+
const b = r(10, 150, 50, 60);
|
|
124
|
+
const out = computePairGap(a, b, 'y');
|
|
125
|
+
expect(out).toEqual({ axis: 'y', gap: 90, from: 60, cross: 30 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('overlapping on the axis → null (no gap to show)', () => {
|
|
129
|
+
const a = r(0, 0, 100, 50);
|
|
130
|
+
const b = r(50, 0, 100, 50); // overlaps a on x
|
|
131
|
+
expect(computePairGap(a, b, 'x')).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('touching (gap === 0) → null', () => {
|
|
135
|
+
const a = r(0, 0, 100, 50);
|
|
136
|
+
const b = r(100, 0, 100, 50);
|
|
137
|
+
expect(computePairGap(a, b, 'x')).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Task L5 — distribute / align / tidy-up pure math.
|
|
142
|
+
|
|
143
|
+
describe('computeAlign', () => {
|
|
144
|
+
test('fewer than 2 rects → []', () => {
|
|
145
|
+
expect(computeAlign([], 'left')).toEqual([]);
|
|
146
|
+
expect(computeAlign([kr('a', 0, 0)], 'left')).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('left/right/top/bottom hold the union-bbox edge', () => {
|
|
150
|
+
const rects = [kr('a', 0, 0, 100, 50), kr('b', 40, 100, 60, 50), kr('c', 200, 30, 100, 50)];
|
|
151
|
+
const left = computeAlign(rects, 'left');
|
|
152
|
+
// union xMin = 0 → 'a' already at x=0 is a no-op (skipped); b/c move.
|
|
153
|
+
expect(left).toEqual(
|
|
154
|
+
expect.arrayContaining([
|
|
155
|
+
{ key: 'b', x: 0, y: 100 },
|
|
156
|
+
{ key: 'c', x: 0, y: 30 },
|
|
157
|
+
])
|
|
158
|
+
);
|
|
159
|
+
expect(left.find((m) => m.key === 'a')).toBeUndefined();
|
|
160
|
+
|
|
161
|
+
const right = computeAlign(rects, 'right');
|
|
162
|
+
// union xMax = 300 (c: 200+100)
|
|
163
|
+
expect(right.find((m) => m.key === 'a')).toEqual({ key: 'a', x: 200, y: 0 });
|
|
164
|
+
expect(right.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 240, y: 100 });
|
|
165
|
+
expect(right.find((m) => m.key === 'c')).toBeUndefined(); // already at right edge
|
|
166
|
+
|
|
167
|
+
const top = computeAlign(rects, 'top');
|
|
168
|
+
expect(top.find((m) => m.key === 'a')).toBeUndefined(); // already yMin=0
|
|
169
|
+
expect(top.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 40, y: 0 });
|
|
170
|
+
expect(top.find((m) => m.key === 'c')).toEqual({ key: 'c', x: 200, y: 0 });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('center-x / center-y align to the union-bbox midpoint', () => {
|
|
174
|
+
const rects = [kr('a', 0, 0, 100, 100), kr('b', 300, 0, 100, 100)];
|
|
175
|
+
// union bbox x: 0..400, cx=200
|
|
176
|
+
const centerX = computeAlign(rects, 'center-x');
|
|
177
|
+
expect(centerX).toEqual(
|
|
178
|
+
expect.arrayContaining([
|
|
179
|
+
{ key: 'a', x: 150, y: 0 },
|
|
180
|
+
{ key: 'b', x: 150, y: 0 },
|
|
181
|
+
])
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('no-op rects (already aligned) are omitted from the result', () => {
|
|
186
|
+
const rects = [kr('a', 0, 0), kr('b', 0, 50)];
|
|
187
|
+
expect(computeAlign(rects, 'left')).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('computeDistribute', () => {
|
|
192
|
+
test('fewer than 3 rects → []', () => {
|
|
193
|
+
expect(computeDistribute([kr('a', 0, 0), kr('b', 200, 0)], 'x')).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('holds first + last, spaces the middle evenly on x', () => {
|
|
197
|
+
// 0..100, then a bunched pair at 110/130, then 300..400 — total span
|
|
198
|
+
// first.x(0) to last.x+w(400) = 400, minus side-lengths(100+80+90+100=370) = 30 → gap 10
|
|
199
|
+
const rects = [
|
|
200
|
+
kr('a', 0, 0, 100, 50),
|
|
201
|
+
kr('b', 110, 0, 80, 50),
|
|
202
|
+
kr('c', 130, 0, 90, 50),
|
|
203
|
+
kr('d', 300, 0, 100, 50),
|
|
204
|
+
];
|
|
205
|
+
const out = computeDistribute(rects, 'x');
|
|
206
|
+
expect(out).toHaveLength(2); // only the 2 middle rects move
|
|
207
|
+
expect(out.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 110, y: 0 });
|
|
208
|
+
expect(out.find((m) => m.key === 'c')).toEqual({ key: 'c', x: 200, y: 0 });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('already-equal gaps still returns the (unchanged) middle positions', () => {
|
|
212
|
+
const rects = [kr('a', 0, 0), kr('b', 120, 0), kr('c', 240, 0)];
|
|
213
|
+
const out = computeDistribute(rects, 'x');
|
|
214
|
+
expect(out).toEqual([{ key: 'b', x: 120, y: 0 }]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('distributes on y', () => {
|
|
218
|
+
// y: 0..60, 90..150, 300..360 — total sides 180, span 360-180=180, gap 90
|
|
219
|
+
const rects = [kr('a', 0, 0, 50, 60), kr('b', 0, 90, 50, 60), kr('c', 0, 300, 50, 60)];
|
|
220
|
+
const out = computeDistribute(rects, 'y');
|
|
221
|
+
expect(out).toHaveLength(1);
|
|
222
|
+
expect(out[0]).toEqual({ key: 'b', x: 0, y: 150 });
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('computeTidyGrid', () => {
|
|
227
|
+
test('fewer than 2 rects → []', () => {
|
|
228
|
+
expect(computeTidyGrid([kr('a', 5, 5)])).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('4 rects → 2×2 grid, reading-order sorted, from the union-bbox origin', () => {
|
|
232
|
+
// scattered, deliberately out of reading order
|
|
233
|
+
const rects = [
|
|
234
|
+
kr('br', 500, 500, 50, 50),
|
|
235
|
+
kr('tl', 0, 0, 50, 50),
|
|
236
|
+
kr('tr', 400, 10, 50, 50),
|
|
237
|
+
kr('bl', 20, 490, 50, 50),
|
|
238
|
+
];
|
|
239
|
+
const out = computeTidyGrid(rects, { gap: 10 });
|
|
240
|
+
expect(out).toHaveLength(4);
|
|
241
|
+
const byKey = Object.fromEntries(out.map((m) => [m.key, m]));
|
|
242
|
+
// reading order (y then x): tl(0,0) → tr(400,10) → bl(20,490) → br(500,500)
|
|
243
|
+
// columns = round(sqrt(4)) = 2 → row0: tl,tr · row1: bl,br
|
|
244
|
+
expect(byKey.tl).toEqual({ key: 'tl', x: 0, y: 0 });
|
|
245
|
+
expect(byKey.tr).toEqual({ key: 'tr', x: 60, y: 0 }); // col0 width 50 + gap 10
|
|
246
|
+
expect(byKey.bl).toEqual({ key: 'bl', x: 0, y: 60 });
|
|
247
|
+
expect(byKey.br).toEqual({ key: 'br', x: 60, y: 60 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('honors an explicit column count', () => {
|
|
251
|
+
const rects = [kr('a', 0, 0, 40, 20), kr('b', 0, 0, 40, 20), kr('c', 0, 0, 40, 20)];
|
|
252
|
+
const out = computeTidyGrid(rects, { gap: 5, columns: 1 });
|
|
253
|
+
// 1 column → stacked vertically
|
|
254
|
+
expect(out.map((m) => m.x)).toEqual([0, 0, 0]);
|
|
255
|
+
expect(out.map((m) => m.y)).toEqual([0, 25, 50]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// export-center — feature-background-export-notification-center.
|
|
2
|
+
//
|
|
3
|
+
// useExportCenter relies on effects (initial fetch + a status-transition
|
|
4
|
+
// watcher), so — like canvas-hmr-runtime.test.tsx — this needs a live DOM
|
|
5
|
+
// with real effect flushing, not renderToStaticMarkup. Registers happy-dom
|
|
6
|
+
// for this file only and drives a real createRoot.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
9
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
GlobalRegistrator.register();
|
|
13
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
});
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
GlobalRegistrator.unregister();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
import { act, createElement } from 'react';
|
|
20
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
21
|
+
|
|
22
|
+
import { useExportCenter } from '../client/export-center.jsx';
|
|
23
|
+
|
|
24
|
+
function mount(el: HTMLElement) {
|
|
25
|
+
let root!: Root;
|
|
26
|
+
act(() => {
|
|
27
|
+
root = createRoot(el);
|
|
28
|
+
});
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function job(overrides: Record<string, unknown>) {
|
|
33
|
+
return {
|
|
34
|
+
id: 'j1',
|
|
35
|
+
format: 'png',
|
|
36
|
+
scope: 'artboard',
|
|
37
|
+
status: 'queued',
|
|
38
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('useExportCenter', () => {
|
|
44
|
+
let originalFetch: typeof fetch;
|
|
45
|
+
let originalHasFocus: typeof document.hasFocus;
|
|
46
|
+
let fetchMock: ReturnType<typeof mock>;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
originalFetch = globalThis.fetch;
|
|
50
|
+
originalHasFocus = document.hasFocus;
|
|
51
|
+
// The hook's initial-hydration GET must never throw synchronously in this
|
|
52
|
+
// no-server test environment; individual tests override this further.
|
|
53
|
+
fetchMock = mock(async () => new Response(JSON.stringify({ jobs: [] }), { status: 200 }));
|
|
54
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
globalThis.fetch = originalFetch;
|
|
58
|
+
document.hasFocus = originalHasFocus;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('upserts a queued→running→done sequence and derives runningCount/queuedCount', () => {
|
|
62
|
+
document.hasFocus = () => false; // keep the focus-gated auto-download inert here
|
|
63
|
+
const host = document.createElement('div');
|
|
64
|
+
const root = mount(host);
|
|
65
|
+
let center!: ReturnType<typeof useExportCenter>;
|
|
66
|
+
function Probe() {
|
|
67
|
+
center = useExportCenter();
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
act(() => {
|
|
71
|
+
root.render(createElement(Probe));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
act(() => {
|
|
75
|
+
center.upsert(job({ status: 'queued' }));
|
|
76
|
+
});
|
|
77
|
+
expect(center.queuedCount).toBe(1);
|
|
78
|
+
expect(center.runningCount).toBe(0);
|
|
79
|
+
expect(center.jobs.map((j) => j.id)).toEqual(['j1']);
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
center.upsert(job({ status: 'running', progress: { current: 2, total: 5 } }));
|
|
83
|
+
});
|
|
84
|
+
expect(center.queuedCount).toBe(0);
|
|
85
|
+
expect(center.runningCount).toBe(1);
|
|
86
|
+
expect(center.busyCount).toBe(1);
|
|
87
|
+
expect(center.jobs[0].progress).toEqual({ current: 2, total: 5 });
|
|
88
|
+
|
|
89
|
+
act(() => {
|
|
90
|
+
center.upsert(job({ status: 'done', filename: 'export.png' }));
|
|
91
|
+
});
|
|
92
|
+
expect(center.runningCount).toBe(0);
|
|
93
|
+
expect(center.busyCount).toBe(0);
|
|
94
|
+
// A done/failed TRANSITION queues a toast.
|
|
95
|
+
expect(center.showToast).toBe(true);
|
|
96
|
+
expect(center.toastJob?.id).toBe('j1');
|
|
97
|
+
|
|
98
|
+
act(() => root.unmount());
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a later upsert of an already-finished job does not re-queue the toast', () => {
|
|
102
|
+
document.hasFocus = () => false;
|
|
103
|
+
const host = document.createElement('div');
|
|
104
|
+
const root = mount(host);
|
|
105
|
+
let center!: ReturnType<typeof useExportCenter>;
|
|
106
|
+
function Probe() {
|
|
107
|
+
center = useExportCenter();
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
act(() => {
|
|
111
|
+
root.render(createElement(Probe));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
act(() => {
|
|
115
|
+
center.upsert(job({ status: 'done', filename: 'export.png' }));
|
|
116
|
+
});
|
|
117
|
+
expect(center.showToast).toBe(true);
|
|
118
|
+
act(() => {
|
|
119
|
+
center.dismissToast();
|
|
120
|
+
});
|
|
121
|
+
expect(center.showToast).toBe(false);
|
|
122
|
+
|
|
123
|
+
// Re-upserting the SAME finished status (no transition) must not resurrect the toast.
|
|
124
|
+
act(() => {
|
|
125
|
+
center.upsert(job({ status: 'done', filename: 'export.png' }));
|
|
126
|
+
});
|
|
127
|
+
expect(center.showToast).toBe(false);
|
|
128
|
+
|
|
129
|
+
act(() => root.unmount());
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('focus-gated auto-download only fires when document.hasFocus() is true', () => {
|
|
133
|
+
const host = document.createElement('div');
|
|
134
|
+
const root = mount(host);
|
|
135
|
+
let center!: ReturnType<typeof useExportCenter>;
|
|
136
|
+
function Probe() {
|
|
137
|
+
center = useExportCenter();
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
act(() => {
|
|
141
|
+
root.render(createElement(Probe));
|
|
142
|
+
});
|
|
143
|
+
fetchMock.mockClear(); // drop the initial hydration GET from the call log
|
|
144
|
+
|
|
145
|
+
// Unfocused — a 'done' transition must NOT hit the download endpoint.
|
|
146
|
+
document.hasFocus = () => false;
|
|
147
|
+
act(() => {
|
|
148
|
+
center.upsert(job({ id: 'unfocused', status: 'done', filename: 'a.png' }));
|
|
149
|
+
});
|
|
150
|
+
const downloadCalls = fetchMock.mock.calls.filter((c) =>
|
|
151
|
+
String(c[0]).includes('/_api/export-jobs/download')
|
|
152
|
+
);
|
|
153
|
+
expect(downloadCalls.length).toBe(0);
|
|
154
|
+
|
|
155
|
+
// Focused — a DIFFERENT job's 'done' transition MUST hit the download endpoint.
|
|
156
|
+
document.hasFocus = () => true;
|
|
157
|
+
act(() => {
|
|
158
|
+
center.upsert(job({ id: 'focused', status: 'done', filename: 'b.png' }));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
act(() => root.unmount());
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('a job first seen queued surfaces a "started" toast that morphs into "done" in place', () => {
|
|
165
|
+
document.hasFocus = () => false;
|
|
166
|
+
const host = document.createElement('div');
|
|
167
|
+
const root = mount(host);
|
|
168
|
+
let center!: ReturnType<typeof useExportCenter>;
|
|
169
|
+
function Probe() {
|
|
170
|
+
center = useExportCenter();
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
act(() => {
|
|
174
|
+
root.render(createElement(Probe));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
act(() => {
|
|
178
|
+
center.upsert(job({ status: 'queued' }));
|
|
179
|
+
});
|
|
180
|
+
expect(center.showToast).toBe(true);
|
|
181
|
+
expect(center.toastJob?.id).toBe('j1');
|
|
182
|
+
expect(center.toastJob?.status).toBe('queued');
|
|
183
|
+
|
|
184
|
+
act(() => {
|
|
185
|
+
center.upsert(job({ status: 'running', progress: { current: 1, total: 3 } }));
|
|
186
|
+
});
|
|
187
|
+
// Still the SAME toast (one entry per job), just reflecting the live status.
|
|
188
|
+
expect(center.showToast).toBe(true);
|
|
189
|
+
expect(center.toastJob?.id).toBe('j1');
|
|
190
|
+
expect(center.toastJob?.status).toBe('running');
|
|
191
|
+
|
|
192
|
+
act(() => {
|
|
193
|
+
center.upsert(job({ status: 'done', filename: 'export.png' }));
|
|
194
|
+
});
|
|
195
|
+
expect(center.showToast).toBe(true);
|
|
196
|
+
expect(center.toastJob?.id).toBe('j1');
|
|
197
|
+
expect(center.toastJob?.status).toBe('done');
|
|
198
|
+
|
|
199
|
+
act(() => root.unmount());
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('hydration seeds prior status — a historical done job never re-toasts or re-downloads', async () => {
|
|
203
|
+
fetchMock = mock(async (url: string) => {
|
|
204
|
+
if (String(url).includes('/_api/export-jobs') && !String(url).includes('download')) {
|
|
205
|
+
return new Response(
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
jobs: [
|
|
208
|
+
job({ id: 'old', status: 'done', filename: 'old.png', createdAt: '2020-01-01' }),
|
|
209
|
+
],
|
|
210
|
+
}),
|
|
211
|
+
{ status: 200 }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return new Response(null, { status: 404 });
|
|
215
|
+
});
|
|
216
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
217
|
+
document.hasFocus = () => true; // would auto-download if this were a live transition
|
|
218
|
+
|
|
219
|
+
const host = document.createElement('div');
|
|
220
|
+
const root = mount(host);
|
|
221
|
+
let center!: ReturnType<typeof useExportCenter>;
|
|
222
|
+
function Probe() {
|
|
223
|
+
center = useExportCenter();
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
// The hydration fetch resolves asynchronously — flush it under act().
|
|
227
|
+
await act(async () => {
|
|
228
|
+
root.render(createElement(Probe));
|
|
229
|
+
await Promise.resolve();
|
|
230
|
+
await Promise.resolve();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(center.jobs.map((j) => j.id)).toEqual(['old']);
|
|
234
|
+
expect(center.showToast).toBe(false);
|
|
235
|
+
const downloadCalls = fetchMock.mock.calls.filter((c) =>
|
|
236
|
+
String(c[0]).includes('/_api/export-jobs/download')
|
|
237
|
+
);
|
|
238
|
+
expect(downloadCalls.length).toBe(0);
|
|
239
|
+
|
|
240
|
+
act(() => root.unmount());
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('savingIds reflects a busy state around save()/download()', async () => {
|
|
244
|
+
document.hasFocus = () => false;
|
|
245
|
+
let resolveDownload!: (r: Response) => void;
|
|
246
|
+
fetchMock = mock((url: string) => {
|
|
247
|
+
if (String(url).includes('/_api/export-jobs/download')) {
|
|
248
|
+
return new Promise<Response>((resolve) => {
|
|
249
|
+
resolveDownload = resolve;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return Promise.resolve(new Response(JSON.stringify({ jobs: [] }), { status: 200 }));
|
|
253
|
+
});
|
|
254
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
255
|
+
|
|
256
|
+
const host = document.createElement('div');
|
|
257
|
+
const root = mount(host);
|
|
258
|
+
let center!: ReturnType<typeof useExportCenter>;
|
|
259
|
+
function Probe() {
|
|
260
|
+
center = useExportCenter();
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
act(() => {
|
|
264
|
+
root.render(createElement(Probe));
|
|
265
|
+
});
|
|
266
|
+
act(() => {
|
|
267
|
+
center.upsert(job({ status: 'done', filename: 'export.png' }));
|
|
268
|
+
});
|
|
269
|
+
expect(center.savingIds.has('j1')).toBe(false);
|
|
270
|
+
expect(center.toastJob).not.toBeNull();
|
|
271
|
+
const doneJob = center.toastJob as NonNullable<typeof center.toastJob>;
|
|
272
|
+
|
|
273
|
+
let downloadDone!: Promise<void>;
|
|
274
|
+
act(() => {
|
|
275
|
+
downloadDone = center.download(doneJob);
|
|
276
|
+
});
|
|
277
|
+
expect(center.savingIds.has('j1')).toBe(true);
|
|
278
|
+
|
|
279
|
+
await act(async () => {
|
|
280
|
+
resolveDownload(new Response(new Blob(['x']), { status: 200 }));
|
|
281
|
+
await downloadDone;
|
|
282
|
+
});
|
|
283
|
+
expect(center.savingIds.has('j1')).toBe(false);
|
|
284
|
+
|
|
285
|
+
act(() => root.unmount());
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Regression guard for the multi-target capture position-restore bug
|
|
2
|
+
// (feature-background-export-notification-center, Phase A). Root cause: each
|
|
3
|
+
// per-artboard capture pinned the artboard's `left`/`top` inline style to
|
|
4
|
+
// `0px` for the screenshot/PDF/etc. crop but never restored it, so every
|
|
5
|
+
// artboard processed earlier in a `--multi` loop stayed stacked at the
|
|
6
|
+
// origin and bled into every subsequent target's clip region — the
|
|
7
|
+
// `canvas-as-separate` "scattered/overlapping fragments" bug.
|
|
8
|
+
//
|
|
9
|
+
// Confirmed fixed via a live repro against a real 22-artboard canvas (plan
|
|
10
|
+
// Task 26): the PDF export that previously didn't complete in 3+ minutes now
|
|
11
|
+
// finishes in ~10s with every page showing its own correct, isolated
|
|
12
|
+
// content. That run needs a real Chromium; this suite deliberately doesn't
|
|
13
|
+
// spawn one (mirrors `test/exporters/pw-launch.test.ts`: "the launch path
|
|
14
|
+
// itself is integration-shape... needs a real browser" — CI has none
|
|
15
|
+
// installed). So this guards the FIX SHAPE at the shim source level: a
|
|
16
|
+
// revert that drops the restore step (or reintroduces the old one-shot
|
|
17
|
+
// "zero every artboard up front" pattern) fails loud here instead of only
|
|
18
|
+
// showing up as a live-repro regression nobody runs by hand.
|
|
19
|
+
|
|
20
|
+
import { describe, expect, test } from 'bun:test';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
const BIN_DIR = join(import.meta.dir, '..', 'bin');
|
|
25
|
+
|
|
26
|
+
function readShim(name: string): string {
|
|
27
|
+
return readFileSync(join(BIN_DIR, name), 'utf8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The old bug pattern: zero EVERY `[data-dc-screen]` once, up front, before
|
|
31
|
+
// any per-target loop runs (no restore possible once every artboard shares
|
|
32
|
+
// the same reset).
|
|
33
|
+
const ONE_SHOT_RESET =
|
|
34
|
+
/for \(const el of document\.querySelectorAll\(['"]\[data-dc-screen\]['"]\)\)/;
|
|
35
|
+
|
|
36
|
+
describe('export shims — multi-target position restore (scatter-bug regression)', () => {
|
|
37
|
+
test('_png-playwright.mjs saves + restores each artboard around its own capture', () => {
|
|
38
|
+
const src = readShim('_png-playwright.mjs');
|
|
39
|
+
expect(src).toMatch(/const savedPos = await widenedHandle\.evaluate/);
|
|
40
|
+
expect(src).toMatch(/ab\.style\.left = prev\.left;\s*\n\s*ab\.style\.top = prev\.top;/);
|
|
41
|
+
expect(src).not.toMatch(ONE_SHOT_RESET);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('_pdf-playwright.mjs saves + restores each artboard around its own capture', () => {
|
|
45
|
+
const src = readShim('_pdf-playwright.mjs');
|
|
46
|
+
expect(src).toMatch(/const savedPos = await handle\.evaluate/);
|
|
47
|
+
expect(src).toMatch(/ab\.style\.left = prev\.left;\s*\n\s*ab\.style\.top = prev\.top;/);
|
|
48
|
+
expect(src).not.toMatch(ONE_SHOT_RESET);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('_svg-playwright.mjs pins + restores per-target via pinArtboard (not a one-shot reset)', () => {
|
|
52
|
+
const src = readShim('_svg-playwright.mjs');
|
|
53
|
+
expect(src).toMatch(/const pinArtboard = async/);
|
|
54
|
+
expect(src).toMatch(/const restore = await pinArtboard\(handle\)/);
|
|
55
|
+
expect(src).not.toMatch(ONE_SHOT_RESET);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('_html-playwright.mjs pins + restores per-target via pinArtboard (not a one-shot reset)', () => {
|
|
59
|
+
const src = readShim('_html-playwright.mjs');
|
|
60
|
+
expect(src).toMatch(/const pinArtboard = async/);
|
|
61
|
+
expect(src).toMatch(/const restore = await pinArtboard\(handle\)/);
|
|
62
|
+
expect(src).not.toMatch(ONE_SHOT_RESET);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('_pptx-playwright.mjs saves + restores position around its capture', () => {
|
|
66
|
+
const src = readShim('_pptx-playwright.mjs');
|
|
67
|
+
expect(src).toMatch(/const savedPos = await handle\.evaluate/);
|
|
68
|
+
expect(src).toMatch(/ab\.style\.left = prev\.left;\s*\n\s*ab\.style\.top = prev\.top;/);
|
|
69
|
+
expect(src).not.toMatch(ONE_SHOT_RESET);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('multi-loops emit MAUDE_PROGRESS per artboard (the notification-center signal)', () => {
|
|
73
|
+
for (const name of [
|
|
74
|
+
'_png-playwright.mjs',
|
|
75
|
+
'_pdf-playwright.mjs',
|
|
76
|
+
'_svg-playwright.mjs',
|
|
77
|
+
'_html-playwright.mjs',
|
|
78
|
+
]) {
|
|
79
|
+
const src = readShim(name);
|
|
80
|
+
expect(src).toContain('MAUDE_PROGRESS');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -55,12 +55,12 @@ describe('/_api/export-history — GET', () => {
|
|
|
55
55
|
}
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
test('caps to
|
|
58
|
+
test('caps to 20 most-recent entries', async () => {
|
|
59
59
|
const { root } = makeSandbox();
|
|
60
60
|
const port = nextPort();
|
|
61
61
|
const proc = await bootServer(root, port);
|
|
62
62
|
try {
|
|
63
|
-
for (let i = 0; i <
|
|
63
|
+
for (let i = 0; i < 23; i += 1) {
|
|
64
64
|
const r = await fetch(`http://localhost:${port}/_api/export`, {
|
|
65
65
|
method: 'POST',
|
|
66
66
|
headers: { 'content-type': 'application/json' },
|
|
@@ -70,7 +70,36 @@ describe('/_api/export-history — GET', () => {
|
|
|
70
70
|
}
|
|
71
71
|
const r = await fetch(`http://localhost:${port}/_api/export-history`);
|
|
72
72
|
const body = (await r.json()) as HistoryShape;
|
|
73
|
-
expect(body.history.length).toBe(
|
|
73
|
+
expect(body.history.length).toBe(20);
|
|
74
|
+
} finally {
|
|
75
|
+
await killProc(proc);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('old-shape entries (no id/status) still parse on a fresh boot', async () => {
|
|
80
|
+
const { root, designRoot } = makeSandbox();
|
|
81
|
+
const { mkdirSync, writeFileSync } = await import('node:fs');
|
|
82
|
+
mkdirSync(designRoot, { recursive: true });
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(designRoot, '_export-history.json'),
|
|
85
|
+
JSON.stringify([
|
|
86
|
+
{
|
|
87
|
+
format: 'png',
|
|
88
|
+
scope: 'artboard',
|
|
89
|
+
filename: 'legacy.png',
|
|
90
|
+
at: '2026-01-01T00:00:00.000Z',
|
|
91
|
+
},
|
|
92
|
+
])
|
|
93
|
+
);
|
|
94
|
+
const port = nextPort();
|
|
95
|
+
const proc = await bootServer(root, port);
|
|
96
|
+
try {
|
|
97
|
+
const r = await fetch(`http://localhost:${port}/_api/export-history`);
|
|
98
|
+
expect(r.status).toBe(200);
|
|
99
|
+
const body = (await r.json()) as HistoryShape;
|
|
100
|
+
expect(body.history.length).toBe(1);
|
|
101
|
+
expect(body.history[0].format).toBe('png');
|
|
102
|
+
expect(body.history[0].filename).toBe('legacy.png');
|
|
74
103
|
} finally {
|
|
75
104
|
await killProc(proc);
|
|
76
105
|
}
|