@1agh/maude 0.41.0 → 0.43.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/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/annotations-layer.tsx +1055 -278
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +173 -63
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- 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/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +160 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/canvas-edit.ts +459 -10
- package/apps/studio/canvas-lib.tsx +523 -5
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +476 -88
- package/apps/studio/client/export-center.jsx +460 -0
- package/apps/studio/client/github.js +4 -0
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +437 -0
- package/apps/studio/client/styles/3-shell-maude.css +59 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +31 -6
- package/apps/studio/dist/client.bundle.js +5561 -19
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/export-dialog.tsx +18 -25
- 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 +376 -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 +37 -19
- package/apps/studio/footage/schema.test.ts +195 -0
- package/apps/studio/footage/schema.ts +484 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +198 -20
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +318 -64
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/server.ts +8 -2
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +46 -0
- 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/footage-store.test.ts +100 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +43 -20
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +38 -1
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +70 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/commands/design.mjs +34 -3
- package/cli/lib/gitignore-block.mjs +2 -0
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// feature-background-export-notification-center — job queue concurrency,
|
|
2
|
+
// history persistence under concurrency, and byte retrieval/eviction.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { bootServer, killProc, makeSandbox, nextPort } from '../_helpers.ts';
|
|
10
|
+
|
|
11
|
+
interface JobShape {
|
|
12
|
+
id: string;
|
|
13
|
+
status: 'queued' | 'running' | 'done' | 'failed';
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function listJobs(port: number): Promise<JobShape[]> {
|
|
18
|
+
const r = await fetch(`http://localhost:${port}/_api/export-jobs`);
|
|
19
|
+
const body = (await r.json()) as { jobs: JobShape[] };
|
|
20
|
+
return body.jobs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A `project-raw` zip export takes real, measurable time when the sandbox is
|
|
24
|
+
* large enough (DEFLATE-compressing several MB) — no browser/Playwright
|
|
25
|
+
* needed, so this stays a fast, deterministic-enough integration test. */
|
|
26
|
+
function seedLargeSandbox(designRoot: string, fileCount = 5000): void {
|
|
27
|
+
const dir = join(designRoot, 'ui', 'big');
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
for (let i = 0; i < fileCount; i += 1) {
|
|
30
|
+
writeFileSync(join(dir, `f${i}.txt`), randomBytes(2000).toString('base64'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('exporters/jobs — concurrency gate', () => {
|
|
35
|
+
test('caps concurrent render-heavy jobs at MAUDE_EXPORT_MAX_CONCURRENT', async () => {
|
|
36
|
+
const { root, designRoot } = makeSandbox();
|
|
37
|
+
seedLargeSandbox(designRoot);
|
|
38
|
+
const port = nextPort();
|
|
39
|
+
const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_CONCURRENT: '2' });
|
|
40
|
+
try {
|
|
41
|
+
const jobIds: string[] = [];
|
|
42
|
+
for (let i = 0; i < 3; i += 1) {
|
|
43
|
+
const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'content-type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
|
|
47
|
+
});
|
|
48
|
+
expect(r.status).toBe(202);
|
|
49
|
+
const { jobId } = (await r.json()) as { jobId: string };
|
|
50
|
+
jobIds.push(jobId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let sawThirdQueued = false;
|
|
54
|
+
let maxRunningObserved = 0;
|
|
55
|
+
let allDone = false;
|
|
56
|
+
const deadline = Date.now() + 4000;
|
|
57
|
+
while (Date.now() < deadline && !allDone) {
|
|
58
|
+
const jobs = await listJobs(port);
|
|
59
|
+
const byId = new Map(jobs.map((j) => [j.id, j]));
|
|
60
|
+
const running = jobs.filter((j) => j.status === 'running').length;
|
|
61
|
+
maxRunningObserved = Math.max(maxRunningObserved, running);
|
|
62
|
+
if (byId.get(jobIds[2])?.status === 'queued') sawThirdQueued = true;
|
|
63
|
+
allDone = jobIds.every((id) => byId.get(id)?.status === 'done');
|
|
64
|
+
if (!allDone) await Bun.sleep(15);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(allDone).toBe(true);
|
|
68
|
+
// Never more than the configured cap ran at once.
|
|
69
|
+
expect(maxRunningObserved).toBeLessThanOrEqual(2);
|
|
70
|
+
// The 3rd job was genuinely gated behind the semaphore at some point,
|
|
71
|
+
// not just "coincidentally" always running — proves the queue, not just
|
|
72
|
+
// that everything happened to finish fast.
|
|
73
|
+
expect(sawThirdQueued).toBe(true);
|
|
74
|
+
} finally {
|
|
75
|
+
await killProc(proc);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)', () => {
|
|
81
|
+
test('enqueue() 429s past MAUDE_EXPORT_MAX_QUEUED, then accepts more once jobs drain', async () => {
|
|
82
|
+
const { root, designRoot } = makeSandbox();
|
|
83
|
+
seedLargeSandbox(designRoot); // keeps jobs 'queued'/'running' long enough to observe
|
|
84
|
+
const port = nextPort();
|
|
85
|
+
const proc = await bootServer(root, port, {
|
|
86
|
+
MAUDE_EXPORT_MAX_CONCURRENT: '1',
|
|
87
|
+
MAUDE_EXPORT_MAX_QUEUED: '2',
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
const post = () =>
|
|
91
|
+
fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'content-type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const r1 = await post();
|
|
98
|
+
const r2 = await post();
|
|
99
|
+
expect(r1.status).toBe(202);
|
|
100
|
+
expect(r2.status).toBe(202);
|
|
101
|
+
|
|
102
|
+
// A 3rd job while the first 2 are still queued/running must be rejected
|
|
103
|
+
// with 429, not silently accepted (the DoS-protection cap).
|
|
104
|
+
const r3 = await post();
|
|
105
|
+
expect(r3.status).toBe(429);
|
|
106
|
+
const text = await r3.text();
|
|
107
|
+
expect(text.toLowerCase()).toContain('queue');
|
|
108
|
+
|
|
109
|
+
// Once jobs drain below the cap, enqueue works again — this is a
|
|
110
|
+
// capacity limit, not a permanent lockout.
|
|
111
|
+
let drained = false;
|
|
112
|
+
const deadline = Date.now() + 5000;
|
|
113
|
+
while (Date.now() < deadline && !drained) {
|
|
114
|
+
const jobs = await listJobs(port);
|
|
115
|
+
drained = jobs.every((j) => j.status === 'done' || j.status === 'failed');
|
|
116
|
+
if (!drained) await Bun.sleep(15);
|
|
117
|
+
}
|
|
118
|
+
expect(drained).toBe(true);
|
|
119
|
+
const r4 = await post();
|
|
120
|
+
expect(r4.status).toBe(202);
|
|
121
|
+
} finally {
|
|
122
|
+
await killProc(proc);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('exporters/jobs — list() + history under concurrency', () => {
|
|
128
|
+
test('list() returns newest-first', async () => {
|
|
129
|
+
const { root } = makeSandbox();
|
|
130
|
+
const port = nextPort();
|
|
131
|
+
const proc = await bootServer(root, port);
|
|
132
|
+
try {
|
|
133
|
+
// `canvas-as-separate` against an empty sandbox (`active: null`)
|
|
134
|
+
// resolves to zero targets → the adapter's empty-input branch, no
|
|
135
|
+
// Playwright spawn — fast + deterministic (mirrors endpoint.test.ts).
|
|
136
|
+
for (let i = 0; i < 3; i += 1) {
|
|
137
|
+
const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'content-type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ format: 'png', scope: 'canvas-as-separate' }),
|
|
141
|
+
});
|
|
142
|
+
expect(r.status).toBe(202);
|
|
143
|
+
}
|
|
144
|
+
// Give the fire-and-forget jobs a moment to land.
|
|
145
|
+
await Bun.sleep(200);
|
|
146
|
+
const jobs = await listJobs(port);
|
|
147
|
+
expect(jobs.length).toBeGreaterThanOrEqual(3);
|
|
148
|
+
const createdAts = jobs.map((j) => j.createdAt);
|
|
149
|
+
const sorted = [...createdAts].sort((a, b) => b.localeCompare(a));
|
|
150
|
+
expect(createdAts).toEqual(sorted);
|
|
151
|
+
} finally {
|
|
152
|
+
await killProc(proc);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('history ledger caps at 20 without dropping entries under concurrent completions', async () => {
|
|
157
|
+
const { root } = makeSandbox();
|
|
158
|
+
const port = nextPort();
|
|
159
|
+
// MAUDE_EXPORT_MAX_QUEUED override — this test deliberately fires 25 jobs
|
|
160
|
+
// in one burst (the concurrent-completion race itself), well above the
|
|
161
|
+
// production default's DoS-protection cap; a real flood is hundreds of
|
|
162
|
+
// requests, not a legitimate test burst.
|
|
163
|
+
const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_QUEUED: '30' });
|
|
164
|
+
try {
|
|
165
|
+
// Fire 25 jobs concurrently (not sequentially) — the exact race the old
|
|
166
|
+
// read-modify-write appendExportHistory had.
|
|
167
|
+
await Promise.all(
|
|
168
|
+
Array.from({ length: 25 }, () =>
|
|
169
|
+
fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'content-type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ format: 'png', scope: 'canvas-as-separate' }),
|
|
173
|
+
}).then((r) => expect(r.status).toBe(202))
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
// Poll until the ledger settles at the cap.
|
|
177
|
+
let historyLen = 0;
|
|
178
|
+
const deadline = Date.now() + 4000;
|
|
179
|
+
while (Date.now() < deadline && historyLen < 20) {
|
|
180
|
+
const r = await fetch(`http://localhost:${port}/_api/export-history`);
|
|
181
|
+
const body = (await r.json()) as { history: unknown[] };
|
|
182
|
+
historyLen = body.history.length;
|
|
183
|
+
if (historyLen < 20) await Bun.sleep(20);
|
|
184
|
+
}
|
|
185
|
+
expect(historyLen).toBe(20);
|
|
186
|
+
} finally {
|
|
187
|
+
await killProc(proc);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('exporters/jobs — byte retrieval + eviction', () => {
|
|
193
|
+
test("a finished job's bytes are downloadable, then gone once evicted past the cap", async () => {
|
|
194
|
+
const { root } = makeSandbox();
|
|
195
|
+
const port = nextPort();
|
|
196
|
+
// MAUDE_EXPORT_MAX_QUEUED override — this test pushes 26 jobs through in
|
|
197
|
+
// one burst (deliberately, to force eviction), above the production
|
|
198
|
+
// default's DoS-protection cap.
|
|
199
|
+
const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_QUEUED: '30' });
|
|
200
|
+
try {
|
|
201
|
+
const enqueue = async () => {
|
|
202
|
+
const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'content-type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
|
|
206
|
+
});
|
|
207
|
+
expect(r.status).toBe(202);
|
|
208
|
+
const { jobId } = (await r.json()) as { jobId: string };
|
|
209
|
+
return jobId;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const firstId = await enqueue();
|
|
213
|
+
// Wait for it to finish.
|
|
214
|
+
let done = false;
|
|
215
|
+
const deadline = Date.now() + 3000;
|
|
216
|
+
while (Date.now() < deadline && !done) {
|
|
217
|
+
const jobs = await listJobs(port);
|
|
218
|
+
done = jobs.find((j) => j.id === firstId)?.status === 'done';
|
|
219
|
+
if (!done) await Bun.sleep(15);
|
|
220
|
+
}
|
|
221
|
+
expect(done).toBe(true);
|
|
222
|
+
|
|
223
|
+
const dl = await fetch(`http://localhost:${port}/_api/export-jobs/download?id=${firstId}`);
|
|
224
|
+
expect(dl.status).toBe(200);
|
|
225
|
+
expect(dl.headers.get('Content-Type')).toBe('application/zip');
|
|
226
|
+
const bytes = await dl.arrayBuffer();
|
|
227
|
+
expect(bytes.byteLength).toBeGreaterThan(0);
|
|
228
|
+
|
|
229
|
+
// 404 for an unknown id.
|
|
230
|
+
const missing = await fetch(`http://localhost:${port}/_api/export-jobs/download?id=nope`);
|
|
231
|
+
expect(missing.status).toBe(404);
|
|
232
|
+
|
|
233
|
+
// Push 25 more jobs through so the first one rolls past the 20-cap and
|
|
234
|
+
// gets evicted (bytes removed from disk + dropped from the in-memory
|
|
235
|
+
// map). Eviction runs on EVERY completion, so the oldest jobs drop out
|
|
236
|
+
// of list() entirely well before the last of the 25 finishes — poll for
|
|
237
|
+
// "nothing left pending" rather than tracking specific (possibly
|
|
238
|
+
// already-evicted) ids.
|
|
239
|
+
for (let i = 0; i < 25; i += 1) {
|
|
240
|
+
await enqueue();
|
|
241
|
+
}
|
|
242
|
+
let settled = false;
|
|
243
|
+
const deadline2 = Date.now() + 4000;
|
|
244
|
+
while (Date.now() < deadline2 && !settled) {
|
|
245
|
+
const jobs = await listJobs(port);
|
|
246
|
+
settled = jobs.every((j) => j.status === 'done' || j.status === 'failed');
|
|
247
|
+
if (!settled) await Bun.sleep(15);
|
|
248
|
+
}
|
|
249
|
+
expect(settled).toBe(true);
|
|
250
|
+
|
|
251
|
+
const r = await fetch(`http://localhost:${port}/_api/export-history`);
|
|
252
|
+
const body = (await r.json()) as { history: unknown[] };
|
|
253
|
+
expect(body.history.length).toBe(20);
|
|
254
|
+
|
|
255
|
+
const evicted = await fetch(
|
|
256
|
+
`http://localhost:${port}/_api/export-jobs/download?id=${firstId}`
|
|
257
|
+
);
|
|
258
|
+
expect(evicted.status).toBe(404);
|
|
259
|
+
} finally {
|
|
260
|
+
await killProc(proc);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// footage-store — round-trip + rejection coverage (feature-footage-analysis-director).
|
|
2
|
+
// Uses a real temp designRoot on disk (Bun.write/Bun.file). Mirrors the photo
|
|
3
|
+
// store's contract: valid analysis + EDL persist and read back; invalid input
|
|
4
|
+
// returns a discriminated {ok:false,status} (never throws); traversal params in
|
|
5
|
+
// the asset/slug are rejected by construction.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import type { Context } from '../context.ts';
|
|
12
|
+
import { assetSha8, createFootageStore, edlSlug } from '../footage-store.ts';
|
|
13
|
+
|
|
14
|
+
let root: string;
|
|
15
|
+
let store: ReturnType<typeof createFootageStore>;
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
root = mkdtempSync(path.join(tmpdir(), 'footage-store-'));
|
|
19
|
+
const designRoot = path.join(root, '.design');
|
|
20
|
+
// Only the fields footage-store touches are needed.
|
|
21
|
+
store = createFootageStore({ paths: { designRoot } } as unknown as Context);
|
|
22
|
+
});
|
|
23
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
24
|
+
|
|
25
|
+
describe('assetSha8 / edlSlug extraction', () => {
|
|
26
|
+
test('accepts the sha8 in every form', () => {
|
|
27
|
+
for (const p of ['assets/a1b2c3d4.mp4', '/assets/a1b2c3d4.png', 'a1b2c3d4.mov', 'a1b2c3d4'])
|
|
28
|
+
expect(assetSha8(p)).toBe('a1b2c3d4');
|
|
29
|
+
});
|
|
30
|
+
test('rejects traversal / non-hex', () => {
|
|
31
|
+
for (const p of ['../x', 'assets/../x', 'not-hex!', '', null]) expect(assetSha8(p)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
test('slug accepts kebab, rejects traversal / dots', () => {
|
|
34
|
+
expect(edlSlug('alligators-reel')).toBe('alligators-reel');
|
|
35
|
+
for (const s of ['../evil', 'has.dot', 'UPPER', '/abs', '']) expect(edlSlug(s)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('analysis round-trip', () => {
|
|
40
|
+
test('saves a valid analysis and reads it back', async () => {
|
|
41
|
+
const res = await store.saveAnalysis('assets/a1b2c3d4.mp4', {
|
|
42
|
+
asset: 'assets/a1b2c3d4.mp4',
|
|
43
|
+
durationSec: 10,
|
|
44
|
+
shots: [{ start: 0, end: 3, usable: true, quality: 0.8 }],
|
|
45
|
+
});
|
|
46
|
+
expect(res.ok).toBe(true);
|
|
47
|
+
if (res.ok) expect(res.path).toBe('assets/a1b2c3d4.footage.json');
|
|
48
|
+
const back = await store.getAnalysis('a1b2c3d4');
|
|
49
|
+
expect(back?.shots?.[0].quality).toBe(0.8);
|
|
50
|
+
expect(back?.version).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('rejects an invalid analysis with a 400 (never throws)', async () => {
|
|
54
|
+
const res = await store.saveAnalysis('assets/a1b2c3d4.mp4', { shots: [{ start: 5, end: 5 }] });
|
|
55
|
+
expect(res.ok).toBe(false);
|
|
56
|
+
if (!res.ok) expect(res.status).toBe(400);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('rejects a bad asset param with a 400', async () => {
|
|
60
|
+
const res = await store.saveAnalysis('../evil', {});
|
|
61
|
+
expect(res.ok).toBe(false);
|
|
62
|
+
if (!res.ok) expect(res.status).toBe(400);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('getAnalysis returns null for an absent sidecar', async () => {
|
|
66
|
+
expect(await store.getAnalysis('ffffffff')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('EDL round-trip', () => {
|
|
71
|
+
test('saves a valid EDL under <slug>.edl.json and reads it back', async () => {
|
|
72
|
+
const res = await store.saveEdl('alligators-reel', {
|
|
73
|
+
title: 'Reel',
|
|
74
|
+
fps: 30,
|
|
75
|
+
width: 1920,
|
|
76
|
+
height: 1080,
|
|
77
|
+
beats: [{ clip: 'assets/a1b2c3d4.mp4', startSec: 0, durationFrames: 60, name: 'intro' }],
|
|
78
|
+
});
|
|
79
|
+
expect(res.ok).toBe(true);
|
|
80
|
+
if (res.ok) expect(res.path).toBe('alligators-reel.edl.json');
|
|
81
|
+
const back = await store.getEdl('alligators-reel');
|
|
82
|
+
expect(back?.beats?.[0].name).toBe('intro');
|
|
83
|
+
expect(back?.version).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('rejects an EDL with an unbundled transition', async () => {
|
|
87
|
+
const res = await store.saveEdl('bad-reel', {
|
|
88
|
+
beats: [
|
|
89
|
+
{
|
|
90
|
+
clip: 'assets/a1b2c3d4.mp4',
|
|
91
|
+
startSec: 0,
|
|
92
|
+
durationFrames: 30,
|
|
93
|
+
transition: { presentation: 'zoom', frames: 5 },
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
expect(res.ok).toBe(false);
|
|
98
|
+
if (!res.ok) expect(res.status).toBe(400);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
gitFetchRemote,
|
|
15
15
|
gitFoldDraft,
|
|
16
16
|
gitListBranches,
|
|
17
|
+
gitPull,
|
|
18
|
+
gitPush,
|
|
17
19
|
remoteAheadBehind,
|
|
18
20
|
} from '../git/service.ts';
|
|
19
21
|
|
|
@@ -111,6 +113,30 @@ test('fold: merges the draft into the Shared version locally; a tokenless publis
|
|
|
111
113
|
expect((await gitListBranches(dir)).some((b) => b.name === 'nav')).toBe(true);
|
|
112
114
|
});
|
|
113
115
|
|
|
116
|
+
test('fold: a GitHub remote takes the PR path — pushes the draft, never locally merges main (DDR-162)', async () => {
|
|
117
|
+
// isGitHubRemote(github.com) → PR flow: publish the DRAFT branch (fails here — no
|
|
118
|
+
// real access) and NEVER merge/modify the local Shared version. `false` as the ssh
|
|
119
|
+
// command fails the transport instantly, so there's no network to github.com.
|
|
120
|
+
const prevSsh = process.env.GIT_SSH_COMMAND;
|
|
121
|
+
process.env.GIT_SSH_COMMAND = 'false';
|
|
122
|
+
const shared = (await gitListBranches(dir)).find((b) => b.current)?.name as string;
|
|
123
|
+
sh(['remote', 'add', 'origin', 'git@github.com:fake-org/fake-repo.git']);
|
|
124
|
+
await gitCreateBranch(dir, 'nav'); // now on 'nav'
|
|
125
|
+
writeFileSync(join(dir, 'b.txt'), 'draft work\n');
|
|
126
|
+
sh(['add', '.']);
|
|
127
|
+
sh(['commit', '-q', '-m', 'draft work']);
|
|
128
|
+
const r = await gitFoldDraft(dir, 'nav', undefined, {});
|
|
129
|
+
process.env.GIT_SSH_COMMAND = prevSsh ?? '';
|
|
130
|
+
// The draft push failed (fake repo) → not ok, and NOT the iso transport error…
|
|
131
|
+
expect(r.ok).toBe(false);
|
|
132
|
+
expect(r.error || '').not.toMatch(/unrecognized transport/i);
|
|
133
|
+
// …but crucially the Shared version was NEVER locally merged/modified…
|
|
134
|
+
sh(['checkout', shared]);
|
|
135
|
+
expect(existsSync(join(dir, 'b.txt'))).toBe(false);
|
|
136
|
+
// …and the draft still exists (nothing destroyed on a failed publish).
|
|
137
|
+
expect((await gitListBranches(dir)).some((b) => b.name === 'nav')).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
114
140
|
// ── remote drafts (phase: surface remote branches) ───────────────────────────
|
|
115
141
|
|
|
116
142
|
/** Stand up a bare "remote" with main + a teammate draft, clone it locally (system
|
|
@@ -197,6 +223,77 @@ test('an ssh remote routes to system git, never the iso transport error', async
|
|
|
197
223
|
expect(r.error || '').not.toMatch(/unrecognized transport/i); // didn't fall into iso
|
|
198
224
|
});
|
|
199
225
|
|
|
226
|
+
// ── publish / get-latest over ssh (DDR-131/DDR-133 parity — the gap that shipped) ──
|
|
227
|
+
// gitFetchRemote was hardened for ssh; gitPush/gitPull historically routed on the
|
|
228
|
+
// USE_SYSTEM_GIT env flag only (default off) and fell into iso → "unrecognized
|
|
229
|
+
// transport protocol: ssh". These prove the write paths now take the same gate.
|
|
230
|
+
|
|
231
|
+
test('gitPush: an ssh remote routes to system git, never the iso transport error', async () => {
|
|
232
|
+
process.env.GIT_SSH_COMMAND =
|
|
233
|
+
'ssh -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=no';
|
|
234
|
+
sh(['remote', 'add', 'origin', 'git@nonexistent.invalid:team/app.git']);
|
|
235
|
+
const r = await gitPush(dir, undefined, {});
|
|
236
|
+
process.env.GIT_SSH_COMMAND = '';
|
|
237
|
+
expect(r.ok).toBe(false);
|
|
238
|
+
expect(r.authRequired).toBeFalsy(); // an ssh failure is not a GitHub sign-in prompt
|
|
239
|
+
expect(r.error || '').not.toMatch(/unrecognized transport/i); // didn't fall into iso
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('gitPull: an ssh remote routes to system git, never the iso transport error', async () => {
|
|
243
|
+
process.env.GIT_SSH_COMMAND =
|
|
244
|
+
'ssh -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=no';
|
|
245
|
+
sh(['remote', 'add', 'origin', 'git@nonexistent.invalid:team/app.git']);
|
|
246
|
+
const r = await gitPull(dir, undefined, {});
|
|
247
|
+
process.env.GIT_SSH_COMMAND = '';
|
|
248
|
+
expect(r.ok).toBe(false);
|
|
249
|
+
expect(r.authRequired).toBeFalsy();
|
|
250
|
+
expect(r.error || '').not.toMatch(/unrecognized transport/i);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('SECURITY: gitPush refuses an unsafe (ext::) remote — no spawn, no RCE', async () => {
|
|
254
|
+
// A poisoned .git/config remote — must be refused BEFORE any git spawn (the payload
|
|
255
|
+
// never runs); classifyRemoteUrl reads the url via iso getConfig, not the git binary.
|
|
256
|
+
const sentinel = join(dir, 'PWNED_PUSH');
|
|
257
|
+
sh(['remote', 'add', 'origin', `ext::sh -c "touch ${sentinel}"`]);
|
|
258
|
+
const r = await gitPush(dir, 'tok_should_not_be_used', {});
|
|
259
|
+
expect(r.ok).toBe(false);
|
|
260
|
+
expect(r.error).toMatch(/github\.com/i); // "can only sync github.com…"
|
|
261
|
+
expect(existsSync(sentinel)).toBe(false); // payload NEVER executed
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('SECURITY: gitPush does not send the GitHub token to a non-GitHub HTTPS host', async () => {
|
|
265
|
+
sh(['remote', 'add', 'origin', 'https://evil.example.com/repo.git']);
|
|
266
|
+
const r = await gitPush(dir, 'tok_secret', {});
|
|
267
|
+
expect(r.ok).toBe(false);
|
|
268
|
+
expect(r.error).toMatch(/github\.com/i); // refused by policy, token never attached
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('SECURITY: a backslash-@ host-confusion remote is refused, PAT never attached (F1)', async () => {
|
|
272
|
+
// `https://github.com\@evil` — new URL() parses host=github.com (backslash → '/'),
|
|
273
|
+
// but git/curl dials `evil` (backslash = path/userinfo char). The token-host allowlist
|
|
274
|
+
// must reject the parser differential and NOT lend the PAT — on every token path:
|
|
275
|
+
// explicit fetch, the unattended ahead/behind probe (the zero-click trigger), and push.
|
|
276
|
+
sh(['remote', 'add', 'origin', 'https://github.com\\@evil.example/x.git']);
|
|
277
|
+
const f = await gitFetchRemote(dir, 'tok_secret');
|
|
278
|
+
expect(f.ok).toBe(false);
|
|
279
|
+
expect(f.error).toMatch(/github\.com/i); // refused by policy — token never attached
|
|
280
|
+
const ab = await remoteAheadBehind(dir, 'tok_secret');
|
|
281
|
+
expect(ab).toEqual({ ahead: 0, behind: 0 }); // the unattended probe refuses too (0/0, no fetch)
|
|
282
|
+
const p = await gitPush(dir, 'tok_secret', {});
|
|
283
|
+
expect(p.ok).toBe(false);
|
|
284
|
+
expect(p.error).toMatch(/github\.com/i);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('SECURITY: the unattended probe refuses a repo with a url.insteadOf rewrite (verify re-review)', async () => {
|
|
288
|
+
// A clean github `origin.url` (passes isTrustedTokenHost) + a poisoned
|
|
289
|
+
// `url.<attacker>.insteadOf` that redirects github.com → attacker (only the system
|
|
290
|
+
// engine honors it). git would dial the attacker; the zero-click probe must refuse.
|
|
291
|
+
sh(['remote', 'add', 'origin', 'https://github.com/owner/repo.git']);
|
|
292
|
+
sh(['config', '--local', 'url.https://attacker.example/.insteadOf', 'https://github.com/']);
|
|
293
|
+
const ab = await remoteAheadBehind(dir, 'tok_secret');
|
|
294
|
+
expect(ab).toEqual({ ahead: 0, behind: 0 });
|
|
295
|
+
});
|
|
296
|
+
|
|
200
297
|
// ── transport-injection hardening (adversarial review of 75a2f0d) ────────────
|
|
201
298
|
|
|
202
299
|
test('SECURITY: refuses to fetch an `ext::` (command-executing) remote — no spawn, no RCE', async () => {
|
|
@@ -18,6 +18,7 @@ import git from 'isomorphic-git';
|
|
|
18
18
|
import type { Context } from '../context.ts';
|
|
19
19
|
import { createGitHubEndpoints, __testing as epTesting } from '../github/endpoints.ts';
|
|
20
20
|
import {
|
|
21
|
+
createPullRequest,
|
|
21
22
|
createRepo,
|
|
22
23
|
GitHubApiError,
|
|
23
24
|
getIdentity,
|
|
@@ -98,6 +99,59 @@ describe('GitHub REST request construction', () => {
|
|
|
98
99
|
expect((err as GitHubApiError).message).toMatch(/already have a project with that name/i);
|
|
99
100
|
});
|
|
100
101
|
|
|
102
|
+
test('createPullRequest POSTs title/head/base/body and shapes the result', async () => {
|
|
103
|
+
const calls = stubFetch(() =>
|
|
104
|
+
json({ number: 12, html_url: 'https://github.com/octocat/acme/pull/12' }, 201)
|
|
105
|
+
);
|
|
106
|
+
const pr = await createPullRequest('t', 'octocat', 'acme', {
|
|
107
|
+
head: 'nav-redesign',
|
|
108
|
+
base: 'main',
|
|
109
|
+
title: 'Add draft',
|
|
110
|
+
body: 'hi',
|
|
111
|
+
});
|
|
112
|
+
expect(pr).toEqual({ number: 12, html_url: 'https://github.com/octocat/acme/pull/12' });
|
|
113
|
+
expect(calls[0].url).toBe('https://api.github.com/repos/octocat/acme/pulls');
|
|
114
|
+
expect(calls[0].init?.method).toBe('POST');
|
|
115
|
+
expect(JSON.parse(calls[0].init?.body as string)).toEqual({
|
|
116
|
+
title: 'Add draft',
|
|
117
|
+
head: 'nav-redesign',
|
|
118
|
+
base: 'main',
|
|
119
|
+
body: 'hi',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('createPullRequest recovers the existing PR link on a 422 (already exists)', async () => {
|
|
124
|
+
const calls = stubFetch((_url, init) =>
|
|
125
|
+
init?.method === 'POST'
|
|
126
|
+
? json({ message: 'A pull request already exists for octocat:nav.' }, 422)
|
|
127
|
+
: json([{ number: 7, html_url: 'https://github.com/octocat/acme/pull/7' }])
|
|
128
|
+
);
|
|
129
|
+
const pr = await createPullRequest('t', 'octocat', 'acme', {
|
|
130
|
+
head: 'nav',
|
|
131
|
+
base: 'main',
|
|
132
|
+
title: 'x',
|
|
133
|
+
});
|
|
134
|
+
expect(pr).toEqual({ number: 7, html_url: 'https://github.com/octocat/acme/pull/7' });
|
|
135
|
+
// the recovery GET filters by head=owner:branch + base + state=open
|
|
136
|
+
expect(calls[1].url).toContain('head=octocat%3Anav');
|
|
137
|
+
expect(calls[1].url).toContain('base=main');
|
|
138
|
+
expect(calls[1].url).toContain('state=open');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('createPullRequest maps a 422 with no existing PR to a "nothing new" message', async () => {
|
|
142
|
+
stubFetch((_url, init) =>
|
|
143
|
+
init?.method === 'POST' ? json({ message: 'No commits between main and nav' }, 422) : json([])
|
|
144
|
+
);
|
|
145
|
+
const err = await createPullRequest('t', 'octocat', 'acme', {
|
|
146
|
+
head: 'nav',
|
|
147
|
+
base: 'main',
|
|
148
|
+
title: 'x',
|
|
149
|
+
}).catch((e) => e);
|
|
150
|
+
expect(err).toBeInstanceOf(GitHubApiError);
|
|
151
|
+
expect((err as GitHubApiError).status).toBe(422);
|
|
152
|
+
expect((err as GitHubApiError).message).toMatch(/nothing new to add/i);
|
|
153
|
+
});
|
|
154
|
+
|
|
101
155
|
test('inviteCollaborator PUTs permission=push, 201 = invited', async () => {
|
|
102
156
|
const calls = stubFetch(() => json({ id: 1 }, 201));
|
|
103
157
|
const r = await inviteCollaborator('t', 'octocat', 'acme', 'anna');
|
|
@@ -242,7 +296,8 @@ describe('token bridge fetch', () => {
|
|
|
242
296
|
const calls = stubFetch(() => new Response('gho_secret\n', { status: 200 }));
|
|
243
297
|
expect(tokenBridgeAvailable()).toBe(true);
|
|
244
298
|
expect(await getGithubToken()).toBe('gho_secret');
|
|
245
|
-
|
|
299
|
+
const headers = calls[0].init?.headers as Record<string, string>;
|
|
300
|
+
expect(headers['X-Maude-Token-Key']).toBe('k123');
|
|
246
301
|
});
|
|
247
302
|
|
|
248
303
|
test('returns null on 404 (not signed in) and 403 (bad key)', async () => {
|
|
@@ -348,6 +348,39 @@ describe('input-router / isEditableTarget', () => {
|
|
|
348
348
|
const el = { tagName: 'DIV', isContentEditable: true } as HTMLElement;
|
|
349
349
|
expect(isEditableTarget(el)).toBe(true);
|
|
350
350
|
});
|
|
351
|
+
|
|
352
|
+
// Dogfood fix — the reported bug: typing "R" while editing in-canvas
|
|
353
|
+
// element text (canvas-shell.tsx's contenteditable="plaintext-only") fired
|
|
354
|
+
// the Rectangle tool shortcut. `.isContentEditable` SHOULD reflect
|
|
355
|
+
// "plaintext-only" per spec, but engine/version inconsistencies mean it
|
|
356
|
+
// can't be the only check — these lock in the raw-attribute fallback that
|
|
357
|
+
// now makes isEditableTarget resilient regardless.
|
|
358
|
+
test('contenteditable="plaintext-only" (isContentEditable false or unreliable) → true', () => {
|
|
359
|
+
const el = {
|
|
360
|
+
tagName: 'DIV',
|
|
361
|
+
isContentEditable: false,
|
|
362
|
+
getAttribute: (name: string) => (name === 'contenteditable' ? 'plaintext-only' : null),
|
|
363
|
+
} as unknown as HTMLElement;
|
|
364
|
+
expect(isEditableTarget(el)).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('bare contenteditable="" attribute → true', () => {
|
|
368
|
+
const el = {
|
|
369
|
+
tagName: 'DIV',
|
|
370
|
+
isContentEditable: false,
|
|
371
|
+
getAttribute: (name: string) => (name === 'contenteditable' ? '' : null),
|
|
372
|
+
} as unknown as HTMLElement;
|
|
373
|
+
expect(isEditableTarget(el)).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('plain div WITH a getAttribute method (real-element shape) still → false', () => {
|
|
377
|
+
const el = {
|
|
378
|
+
tagName: 'DIV',
|
|
379
|
+
isContentEditable: false,
|
|
380
|
+
getAttribute: () => null,
|
|
381
|
+
} as unknown as HTMLElement;
|
|
382
|
+
expect(isEditableTarget(el)).toBe(false);
|
|
383
|
+
});
|
|
351
384
|
});
|
|
352
385
|
|
|
353
386
|
// T25 — Drag-vs-click threshold. Centralized here so every drag-class gesture
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// inspect-script-syntax.test.ts — regression guard for a real bug hit while
|
|
2
|
+
// adding the photo-editor's background-removal busy shimmer: INSPECTOR_SCRIPT
|
|
3
|
+
// (inspect.ts) is itself a giant template literal, so a stray backtick in an
|
|
4
|
+
// inline comment silently truncates/reopens it — tsc DOES catch backtick
|
|
5
|
+
// imbalance (it breaks the outer .ts file's own syntax), but nothing catches
|
|
6
|
+
// any OTHER malformed-JS mistake inside the injected string (e.g. a stray
|
|
7
|
+
// unescaped quote, mismatched braces in the CSS array). This test extracts the
|
|
8
|
+
// actual injected <script> body and syntax-checks it directly.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { type Context, createBus } from '../context.ts';
|
|
14
|
+
import { createInspect } from '../inspect.ts';
|
|
15
|
+
import { makeSandbox } from './_helpers.ts';
|
|
16
|
+
|
|
17
|
+
function mkCtx(root: string, designRoot: string): Context {
|
|
18
|
+
return {
|
|
19
|
+
cfg: {} as Context['cfg'],
|
|
20
|
+
projectLabel: 'test',
|
|
21
|
+
bus: createBus(),
|
|
22
|
+
paths: {
|
|
23
|
+
repoRoot: root,
|
|
24
|
+
designRel: '.design',
|
|
25
|
+
designRoot,
|
|
26
|
+
serverInfoFile: join(designRoot, '_server.json'),
|
|
27
|
+
activeFile: join(designRoot, '_active.json'),
|
|
28
|
+
commentsDir: join(designRoot, '_comments'),
|
|
29
|
+
canvasStateDir: join(designRoot, '_canvas-state'),
|
|
30
|
+
historyDir: join(designRoot, '_history'),
|
|
31
|
+
tokensUrlRel: '',
|
|
32
|
+
systemDirRel: 'system',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('injectInspector — injected <script> is syntactically valid JS', () => {
|
|
38
|
+
test('the extracted script body compiles with no SyntaxError', () => {
|
|
39
|
+
const { root, designRoot } = makeSandbox();
|
|
40
|
+
const inspect = createInspect(mkCtx(root, designRoot), async () => []);
|
|
41
|
+
const out = inspect.injectInspector('<html><body></body></html>');
|
|
42
|
+
|
|
43
|
+
const scripts = [...out.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
|
44
|
+
expect(scripts.length).toBeGreaterThan(0);
|
|
45
|
+
|
|
46
|
+
for (const body of scripts) {
|
|
47
|
+
// `new Function` only PARSES the body (no window/document access at
|
|
48
|
+
// construction time) — throws SyntaxError on malformed JS, exactly the
|
|
49
|
+
// class of bug a stray backtick / unbalanced brace produces.
|
|
50
|
+
expect(() => new Function(body)).not.toThrow();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|