@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,61 @@
|
|
|
1
|
+
// photo-canvas-bundle.test.ts — the automated guard for the lazy-bundle
|
|
2
|
+
// acceptance criterion + BUILDER's flagged top risk (feature-photo-editor).
|
|
3
|
+
//
|
|
4
|
+
// A canvas that uses <PhotoLayer> with a NEUTRAL edit must pay ZERO pixi.js cost:
|
|
5
|
+
// the ~500 KB pixi runtime must stay behind a LAZY runtime `import("pixi.js")`,
|
|
6
|
+
// never an eager top-level `import ... from "pixi.js"`. Canvas builds run with
|
|
7
|
+
// `splitting: false`, so this is fragile — a future refactor that turns the
|
|
8
|
+
// compositor's pixi import back into a static import (or statically imports
|
|
9
|
+
// pipeline.ts from canvas-lib) would silently re-break it. This test bundles a
|
|
10
|
+
// real canvas via the actual dev-server pipeline and inspects the output.
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { buildCanvasModule } from '../canvas-build.ts';
|
|
18
|
+
|
|
19
|
+
const CANVAS = `
|
|
20
|
+
import { DesignCanvas, DCArtboard, PhotoLayer } from '@maude/canvas-lib';
|
|
21
|
+
export default function C() {
|
|
22
|
+
return (
|
|
23
|
+
<DesignCanvas>
|
|
24
|
+
<DCArtboard id="a" width={200} height={200}>
|
|
25
|
+
<PhotoLayer source="assets/aaaa1111.png" edit={{}} width={120} height={120} alt="photo" />
|
|
26
|
+
</DCArtboard>
|
|
27
|
+
</DesignCanvas>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
async function buildCanvas(source: string): Promise<string> {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-photo-bundle-'));
|
|
34
|
+
const abs = join(dir, 'probe.tsx');
|
|
35
|
+
writeFileSync(abs, source);
|
|
36
|
+
try {
|
|
37
|
+
const { js } = await buildCanvasModule(abs, source, {});
|
|
38
|
+
return js;
|
|
39
|
+
} finally {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('lazy-bundle guarantee — <PhotoLayer> keeps pixi out of the eager graph', () => {
|
|
45
|
+
test('an unedited PhotoLayer canvas has NO eager `from "pixi.js"` import', async () => {
|
|
46
|
+
const js = await buildCanvas(CANVAS);
|
|
47
|
+
const eager = [...js.matchAll(/import\s[^;]*?from\s*["']pixi\.js["']/g)];
|
|
48
|
+
expect(eager.length).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('pixi is reachable only via a lazy runtime import("pixi.js")', async () => {
|
|
52
|
+
const js = await buildCanvas(CANVAS);
|
|
53
|
+
expect(/import\(\s*["']pixi\.js["']\s*\)/.test(js)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('the canvas still bundles PhotoLayer itself (sanity — the import resolved)', async () => {
|
|
57
|
+
const js = await buildCanvas(CANVAS);
|
|
58
|
+
// isDefaultEdit is the schema helper <PhotoLayer> calls on the render path.
|
|
59
|
+
expect(js.includes('isDefaultEdit') || js.includes('PhotoLayer')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// photo-edit-api.test.ts — the /_api/photo-edit route + photo-store cap stack
|
|
2
|
+
// (feature-photo-editor, Task 23; pairs with Stage C). Mirrors asset-api.test.ts
|
|
3
|
+
// (real-server round-trip) + canvas-origin-gate.test.ts (dual-allowlist proof).
|
|
4
|
+
//
|
|
5
|
+
// Coverage per the plan: schema validation rejects malformed PhotoEdit JSON, the
|
|
6
|
+
// size cap is enforced, path containment holds against a crafted `asset` param,
|
|
7
|
+
// GET/PUT both work, and the route's presence in BOTH allowlists is asserted
|
|
8
|
+
// (reachable + method-gated from the segregated canvas origin — the DDR-088
|
|
9
|
+
// one-list-only 404 bug this test exists to prevent).
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { assetSha8 } from '../photo-store.ts';
|
|
16
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
17
|
+
|
|
18
|
+
const VALID_SHA = 'ab12cd34ef56';
|
|
19
|
+
const VALID_ASSET = `assets/${VALID_SHA}.png`;
|
|
20
|
+
|
|
21
|
+
// ── Pure: sha8 extraction is the primary traversal defense ───────────────────
|
|
22
|
+
describe('assetSha8 — extraction + traversal rejection (pure)', () => {
|
|
23
|
+
test('accepts the forms a caller might hold', () => {
|
|
24
|
+
expect(assetSha8(VALID_ASSET)).toBe(VALID_SHA);
|
|
25
|
+
expect(assetSha8(`/assets/${VALID_SHA}.png`)).toBe(VALID_SHA);
|
|
26
|
+
expect(assetSha8(`${VALID_SHA}.jpg`)).toBe(VALID_SHA);
|
|
27
|
+
expect(assetSha8(VALID_SHA)).toBe(VALID_SHA);
|
|
28
|
+
});
|
|
29
|
+
test('rejects traversal / non-hex / empty', () => {
|
|
30
|
+
for (const bad of [
|
|
31
|
+
null,
|
|
32
|
+
undefined,
|
|
33
|
+
'',
|
|
34
|
+
'../../etc/passwd',
|
|
35
|
+
'assets/../../secret',
|
|
36
|
+
'assets/..%2f..%2fx', // query-decoded to `../../x` before we see it
|
|
37
|
+
'/etc/passwd',
|
|
38
|
+
'not-hex-zzz',
|
|
39
|
+
'ab12cd34/../x',
|
|
40
|
+
'../ab12cd34',
|
|
41
|
+
]) {
|
|
42
|
+
expect(assetSha8(bad)).toBeNull();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── Real-server round-trip ───────────────────────────────────────────────────
|
|
48
|
+
async function withServer(
|
|
49
|
+
fn: (port: number, designRoot: string) => Promise<void>,
|
|
50
|
+
extraEnv?: Record<string, string>
|
|
51
|
+
) {
|
|
52
|
+
const sandbox = makeSandbox();
|
|
53
|
+
const port = nextPort();
|
|
54
|
+
const proc = await bootServer(sandbox.root, port, extraEnv);
|
|
55
|
+
try {
|
|
56
|
+
await fn(port, sandbox.designRoot);
|
|
57
|
+
} finally {
|
|
58
|
+
await killProc(proc);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const put = (port: number, asset: string, body: unknown) =>
|
|
63
|
+
fetch(`http://localhost:${port}/_api/photo-edit?asset=${encodeURIComponent(asset)}`, {
|
|
64
|
+
method: 'PUT',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify(body),
|
|
67
|
+
});
|
|
68
|
+
const get = (port: number, asset: string) =>
|
|
69
|
+
fetch(`http://localhost:${port}/_api/photo-edit?asset=${encodeURIComponent(asset)}`);
|
|
70
|
+
|
|
71
|
+
describe('/_api/photo-edit — GET/PUT round-trip + cap stack (main origin)', () => {
|
|
72
|
+
test('PUT a valid edit persists it; GET reads it back; sidecar lands on disk', async () => {
|
|
73
|
+
await withServer(async (port, designRoot) => {
|
|
74
|
+
const edit = {
|
|
75
|
+
adjustments: { contrast: 0.3, brightness: -0.1 },
|
|
76
|
+
duotone: { enabled: true, colorA: '#1a1a2e', colorB: '#e94560', intensity: 0.8 },
|
|
77
|
+
};
|
|
78
|
+
const res = await put(port, VALID_ASSET, edit);
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
const j = await res.json();
|
|
81
|
+
expect(j.ok).toBe(true);
|
|
82
|
+
expect(j.path).toBe(`assets/${VALID_SHA}.photo.json`);
|
|
83
|
+
expect(j.edit.version).toBe(1); // stamped on write
|
|
84
|
+
|
|
85
|
+
// GET round-trips the same params.
|
|
86
|
+
const got = await (await get(port, VALID_ASSET)).json();
|
|
87
|
+
expect(got.adjustments.contrast).toBe(0.3);
|
|
88
|
+
expect(got.duotone.colorB).toBe('#e94560');
|
|
89
|
+
|
|
90
|
+
// The sidecar actually exists at the content-addressed path.
|
|
91
|
+
const disk = join(designRoot, 'assets', `${VALID_SHA}.photo.json`);
|
|
92
|
+
expect(existsSync(disk)).toBe(true);
|
|
93
|
+
expect(JSON.parse(readFileSync(disk, 'utf8')).version).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('GET of an unedited asset returns {}', async () => {
|
|
98
|
+
await withServer(async (port) => {
|
|
99
|
+
const got = await (await get(port, 'assets/00ff00ff.png')).json();
|
|
100
|
+
expect(got).toEqual({});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('PUT malformed JSON is rejected (unknown key / bad type / out-of-range)', async () => {
|
|
105
|
+
await withServer(async (port) => {
|
|
106
|
+
expect((await put(port, VALID_ASSET, { bogusKey: 1 })).status).toBe(400);
|
|
107
|
+
expect((await put(port, VALID_ASSET, { adjustments: { contrast: 'high' } })).status).toBe(
|
|
108
|
+
400
|
|
109
|
+
);
|
|
110
|
+
expect((await put(port, VALID_ASSET, { adjustments: { contrast: 5 } })).status).toBe(400);
|
|
111
|
+
expect((await put(port, VALID_ASSET, { duotone: { colorA: 'red' } })).status).toBe(400);
|
|
112
|
+
// A crafted non-relative asset path in the body is rejected too.
|
|
113
|
+
expect((await put(port, VALID_ASSET, { source: '/etc/passwd' })).status).toBe(400);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('PUT with a crafted asset param (traversal) is rejected 400', async () => {
|
|
118
|
+
await withServer(async (port) => {
|
|
119
|
+
expect((await put(port, '../../etc/passwd', { adjustments: { contrast: 0.1 } })).status).toBe(
|
|
120
|
+
400
|
|
121
|
+
);
|
|
122
|
+
expect((await get(port, 'assets/../../secret')).status).toBe(200); // GET → {} (null-safe), never leaks
|
|
123
|
+
const leaked = await (await get(port, 'assets/../../secret')).json();
|
|
124
|
+
expect(leaked).toEqual({});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('PUT an oversized body is rejected (size cap)', async () => {
|
|
129
|
+
await withServer(async (port) => {
|
|
130
|
+
// `source` is a valid relative asset path with no length cap in the schema,
|
|
131
|
+
// so it's the field that can legally exceed the 64 KB byte cap. Sits just
|
|
132
|
+
// above the store cap but below the route's readJson ceiling.
|
|
133
|
+
const big = { source: `assets/${'a'.repeat(66000)}` };
|
|
134
|
+
const res = await put(port, VALID_ASSET, big);
|
|
135
|
+
expect(res.status).toBe(413);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('method gate — DELETE is 405', async () => {
|
|
140
|
+
await withServer(async (port) => {
|
|
141
|
+
const res = await fetch(`http://localhost:${port}/_api/photo-edit?asset=${VALID_ASSET}`, {
|
|
142
|
+
method: 'DELETE',
|
|
143
|
+
});
|
|
144
|
+
expect(res.status).toBe(405);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── Dual-allowlist: reachable + method-gated from the canvas origin ──────────
|
|
150
|
+
async function readCanvasOrigin(designRoot: string): Promise<string> {
|
|
151
|
+
for (let i = 0; i < 40; i++) {
|
|
152
|
+
try {
|
|
153
|
+
const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
|
|
154
|
+
if (info.canvasOrigin) return info.canvasOrigin as string;
|
|
155
|
+
} catch {
|
|
156
|
+
/* not written yet */
|
|
157
|
+
}
|
|
158
|
+
await Bun.sleep(50);
|
|
159
|
+
}
|
|
160
|
+
throw new Error('canvasOrigin never appeared in _server.json');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
describe('/_api/photo-edit — dual-allowlist (canvas origin reachable, DDR-088)', () => {
|
|
164
|
+
test('canvas origin: GET is reachable (200, not 404) and DELETE is method-gated (405)', async () => {
|
|
165
|
+
await withServer(
|
|
166
|
+
async (_port, designRoot) => {
|
|
167
|
+
const canvas = await readCanvasOrigin(designRoot);
|
|
168
|
+
// If the route were in CANVAS_SAFE_API only (missing from server.ts's
|
|
169
|
+
// `routes` map), Bun's fetch fall-through would serve it as a FILE → 404.
|
|
170
|
+
// 200 here proves the handler runs → it's in the server.ts routes map too.
|
|
171
|
+
const gRes = await fetch(`${canvas}/_api/photo-edit?asset=${VALID_ASSET}`, {
|
|
172
|
+
signal: AbortSignal.timeout(3000),
|
|
173
|
+
});
|
|
174
|
+
expect(gRes.status).toBe(200);
|
|
175
|
+
// DELETE reaches the handler (405), not the file fall-through (404).
|
|
176
|
+
const dRes = await fetch(`${canvas}/_api/photo-edit?asset=${VALID_ASSET}`, {
|
|
177
|
+
method: 'DELETE',
|
|
178
|
+
signal: AbortSignal.timeout(3000),
|
|
179
|
+
});
|
|
180
|
+
expect(dRes.status).toBe(405);
|
|
181
|
+
},
|
|
182
|
+
{ MAUDE_CANVAS_ORIGIN_SPLIT: '1' }
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('canvas origin: PUT succeeds (no sameOriginWrite block — the headless harness needs this)', async () => {
|
|
187
|
+
await withServer(
|
|
188
|
+
async (_port, designRoot) => {
|
|
189
|
+
const canvas = await readCanvasOrigin(designRoot);
|
|
190
|
+
const res = await fetch(`${canvas}/_api/photo-edit?asset=${VALID_ASSET}`, {
|
|
191
|
+
method: 'PUT',
|
|
192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
193
|
+
body: JSON.stringify({ adjustments: { saturation: 0.5 } }),
|
|
194
|
+
signal: AbortSignal.timeout(3000),
|
|
195
|
+
});
|
|
196
|
+
expect(res.status).toBe(200);
|
|
197
|
+
},
|
|
198
|
+
{ MAUDE_CANVAS_ORIGIN_SPLIT: '1' }
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// photo-filters.test.ts — Stage B, Task 4. Unit-tests the PURE pipeline planner
|
|
2
|
+
// (`planPhotoPipeline`) — filter/step COUNT + ORDER + params, never pixel output
|
|
3
|
+
// (pixel realization is browser-only, in pipeline.ts). No pixi import here: the
|
|
4
|
+
// whole point of the planner split is that this suite runs headlessly.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import type { PhotoPipelineStep } from '../photo/filters.ts';
|
|
9
|
+
import { DUOTONE_FRAG_SOURCE, hexToRgb01, planPhotoPipeline } from '../photo/filters.ts';
|
|
10
|
+
import type { PhotoEdit } from '../photo/schema.ts';
|
|
11
|
+
import { PHOTO_PIPELINE_ORDER } from '../photo/schema.ts';
|
|
12
|
+
|
|
13
|
+
const stages = (steps: PhotoPipelineStep[]) => steps.map((s) => s.stage);
|
|
14
|
+
|
|
15
|
+
describe('planPhotoPipeline — neutrality', () => {
|
|
16
|
+
test('null / empty / all-neutral edits produce no steps', () => {
|
|
17
|
+
expect(planPhotoPipeline(null)).toEqual([]);
|
|
18
|
+
expect(planPhotoPipeline(undefined)).toEqual([]);
|
|
19
|
+
expect(planPhotoPipeline({})).toEqual([]);
|
|
20
|
+
expect(planPhotoPipeline({ adjustments: { brightness: 0, contrast: 0 } })).toEqual([]);
|
|
21
|
+
// Disabled sections never emit, regardless of their params.
|
|
22
|
+
expect(
|
|
23
|
+
planPhotoPipeline({
|
|
24
|
+
duotone: { enabled: false, colorA: '#000000', colorB: '#ffffff', intensity: 1 },
|
|
25
|
+
grain: { enabled: false, amount: 1 },
|
|
26
|
+
pattern: { enabled: false, opacity: 1 },
|
|
27
|
+
mask: { preset: 'none', strength: 1 },
|
|
28
|
+
})
|
|
29
|
+
).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('planPhotoPipeline — ordering', () => {
|
|
34
|
+
test('a fully-loaded edit emits every stage in PHOTO_PIPELINE_ORDER', () => {
|
|
35
|
+
const edit: PhotoEdit = {
|
|
36
|
+
// Deliberately author the object with keys OUT of pipeline order to prove
|
|
37
|
+
// the planner re-orders rather than echoing insertion order.
|
|
38
|
+
mask: { preset: 'vignette', strength: 0.5 },
|
|
39
|
+
pattern: { enabled: true, type: 'grid', scale: 2, opacity: 0.4, blend: 'multiply' },
|
|
40
|
+
grain: { enabled: true, amount: 0.3, size: 2 },
|
|
41
|
+
duotone: { enabled: true, colorA: '#1a1a2e', colorB: '#e94560', intensity: 0.8 },
|
|
42
|
+
adjustments: { contrast: 0.2, brightness: -0.1 },
|
|
43
|
+
};
|
|
44
|
+
const steps = planPhotoPipeline(edit);
|
|
45
|
+
expect(stages(steps)).toEqual([...PHOTO_PIPELINE_ORDER]);
|
|
46
|
+
// Exactly one step per stage.
|
|
47
|
+
expect(steps.length).toBe(5);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('partial edits keep relative order and skip absent stages', () => {
|
|
51
|
+
const steps = planPhotoPipeline({
|
|
52
|
+
mask: { preset: 'edge-fade', strength: 0.7 },
|
|
53
|
+
adjustments: { hue: 40 },
|
|
54
|
+
});
|
|
55
|
+
expect(stages(steps)).toEqual(['adjustments', 'mask']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('planPhotoPipeline — adjustments sub-order + params', () => {
|
|
60
|
+
test('color-matrix ops follow the fixed sub-order and carry normalized values', () => {
|
|
61
|
+
const steps = planPhotoPipeline({
|
|
62
|
+
// authored out of order:
|
|
63
|
+
adjustments: {
|
|
64
|
+
invert: 1,
|
|
65
|
+
brightness: 0.5,
|
|
66
|
+
hue: 90,
|
|
67
|
+
contrast: -0.3,
|
|
68
|
+
grayscale: 0.4,
|
|
69
|
+
saturation: 0.2,
|
|
70
|
+
sepia: 0.1,
|
|
71
|
+
exposure: 0.6,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
expect(steps.length).toBe(1);
|
|
75
|
+
const adj = steps[0];
|
|
76
|
+
expect(adj.stage).toBe('adjustments');
|
|
77
|
+
if (adj.stage !== 'adjustments') throw new Error('unreachable');
|
|
78
|
+
expect(adj.ops.map((o) => o.op)).toEqual([
|
|
79
|
+
'brightness',
|
|
80
|
+
'exposure',
|
|
81
|
+
'contrast',
|
|
82
|
+
'saturation',
|
|
83
|
+
'hue',
|
|
84
|
+
'sepia',
|
|
85
|
+
'grayscale',
|
|
86
|
+
'invert',
|
|
87
|
+
]);
|
|
88
|
+
// Values pass through untouched (normalization/mapping is a realizer concern).
|
|
89
|
+
const byOp = Object.fromEntries(adj.ops.map((o) => [o.op, o.value]));
|
|
90
|
+
expect(byOp.brightness).toBe(0.5);
|
|
91
|
+
expect(byOp.hue).toBe(90);
|
|
92
|
+
expect(byOp.contrast).toBe(-0.3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('only non-neutral adjustment fields become ops', () => {
|
|
96
|
+
const steps = planPhotoPipeline({
|
|
97
|
+
adjustments: { contrast: 0.5, brightness: 0, saturation: 0 },
|
|
98
|
+
});
|
|
99
|
+
const adj = steps[0];
|
|
100
|
+
if (adj.stage !== 'adjustments') throw new Error('expected adjustments');
|
|
101
|
+
expect(adj.ops.map((o) => o.op)).toEqual(['contrast']);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('planPhotoPipeline — section defaults when enabled-but-unset', () => {
|
|
106
|
+
test('enabled duotone/grain/pattern fill sensible defaults', () => {
|
|
107
|
+
const steps = planPhotoPipeline({
|
|
108
|
+
duotone: { enabled: true },
|
|
109
|
+
grain: { enabled: true },
|
|
110
|
+
pattern: { enabled: true },
|
|
111
|
+
});
|
|
112
|
+
const duo = steps.find((s) => s.stage === 'duotone');
|
|
113
|
+
const grain = steps.find((s) => s.stage === 'grain');
|
|
114
|
+
const pat = steps.find((s) => s.stage === 'pattern');
|
|
115
|
+
expect(duo).toBeTruthy();
|
|
116
|
+
expect(grain).toBeTruthy();
|
|
117
|
+
expect(pat).toBeTruthy();
|
|
118
|
+
if (duo?.stage === 'duotone') {
|
|
119
|
+
expect(duo.colorA).toMatch(/^#[0-9a-f]{6}$/);
|
|
120
|
+
expect(duo.colorB).toMatch(/^#[0-9a-f]{6}$/);
|
|
121
|
+
expect(duo.intensity).toBeGreaterThan(0);
|
|
122
|
+
}
|
|
123
|
+
if (pat?.stage === 'pattern') expect(pat.type).toBe('dots');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('hexToRgb01', () => {
|
|
128
|
+
test('parses to normalized rgb', () => {
|
|
129
|
+
expect(hexToRgb01('#000000')).toEqual([0, 0, 0]);
|
|
130
|
+
expect(hexToRgb01('#ffffff')).toEqual([1, 1, 1]);
|
|
131
|
+
const [r, g, b] = hexToRgb01('#804020');
|
|
132
|
+
expect(r).toBeCloseTo(128 / 255, 5);
|
|
133
|
+
expect(g).toBeCloseTo(64 / 255, 5);
|
|
134
|
+
expect(b).toBeCloseTo(32 / 255, 5);
|
|
135
|
+
});
|
|
136
|
+
test('throws on malformed input', () => {
|
|
137
|
+
expect(() => hexToRgb01('nope')).toThrow();
|
|
138
|
+
expect(() => hexToRgb01('#fff')).toThrow();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('duotone shader source', () => {
|
|
143
|
+
test('is a non-empty GLSL string with the expected uniforms', () => {
|
|
144
|
+
expect(DUOTONE_FRAG_SOURCE).toContain('uColorA');
|
|
145
|
+
expect(DUOTONE_FRAG_SOURCE).toContain('uColorB');
|
|
146
|
+
expect(DUOTONE_FRAG_SOURCE).toContain('uIntensity');
|
|
147
|
+
expect(DUOTONE_FRAG_SOURCE).toContain('uTexture');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// photo-pipeline.test.ts — Stage B, Task 5. Exercises ONLY the pure decision
|
|
2
|
+
// logic in pipeline.ts (`adjustmentCalls`, `resolveSourceUrl`, `needsCompositor`)
|
|
3
|
+
// — no pixi filter is constructed, so this runs headlessly. The actual WebGL
|
|
4
|
+
// render is verified via `maude design screenshot` (Task 6) + desktop dogfood
|
|
5
|
+
// (Task 25). This suite ALSO proves importing pipeline.ts (which statically
|
|
6
|
+
// imports pixi.js) does no construction at load time.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import type { AdjustmentsStep } from '../photo/filters.ts';
|
|
11
|
+
import { adjustmentCalls, needsCompositor, resolveSourceUrl } from '../photo/pipeline.ts';
|
|
12
|
+
|
|
13
|
+
const adj = (ops: AdjustmentsStep['ops']): AdjustmentsStep => ({
|
|
14
|
+
stage: 'adjustments',
|
|
15
|
+
kind: 'colorMatrix',
|
|
16
|
+
ops,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('adjustmentCalls — normalized → pixi mapping + neutral references', () => {
|
|
20
|
+
test('brightness delta maps to pixi multiplier (neutral 1)', () => {
|
|
21
|
+
expect(adjustmentCalls(adj([{ op: 'brightness', value: 0 }]))[0]).toEqual({
|
|
22
|
+
method: 'brightness',
|
|
23
|
+
args: [1],
|
|
24
|
+
});
|
|
25
|
+
expect(adjustmentCalls(adj([{ op: 'brightness', value: 0.5 }]))[0].args).toEqual([1.5]);
|
|
26
|
+
expect(adjustmentCalls(adj([{ op: 'brightness', value: -1 }]))[0].args).toEqual([0]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('exposure maps to a 2^stop brightness', () => {
|
|
30
|
+
expect(adjustmentCalls(adj([{ op: 'exposure', value: 0 }]))[0]).toEqual({
|
|
31
|
+
method: 'brightness',
|
|
32
|
+
args: [1],
|
|
33
|
+
});
|
|
34
|
+
expect(adjustmentCalls(adj([{ op: 'exposure', value: 1 }]))[0].args).toEqual([2]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('contrast maps into pixi 0..1 with 0.5 neutral', () => {
|
|
38
|
+
expect(adjustmentCalls(adj([{ op: 'contrast', value: 0 }]))[0].args).toEqual([0.5]);
|
|
39
|
+
expect(adjustmentCalls(adj([{ op: 'contrast', value: 1 }]))[0].args).toEqual([1]);
|
|
40
|
+
expect(adjustmentCalls(adj([{ op: 'contrast', value: -1 }]))[0].args).toEqual([0]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('saturation and hue pass through', () => {
|
|
44
|
+
expect(adjustmentCalls(adj([{ op: 'saturation', value: -0.5 }]))[0]).toEqual({
|
|
45
|
+
method: 'saturate',
|
|
46
|
+
args: [-0.5],
|
|
47
|
+
});
|
|
48
|
+
expect(adjustmentCalls(adj([{ op: 'hue', value: 120 }]))[0]).toEqual({
|
|
49
|
+
method: 'hue',
|
|
50
|
+
args: [120],
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('sepia and invert become isolate-alpha toggles (amount honored)', () => {
|
|
55
|
+
const sepia = adjustmentCalls(adj([{ op: 'sepia', value: 0.7 }]))[0];
|
|
56
|
+
expect(sepia.method).toBe('sepia');
|
|
57
|
+
expect(sepia.args).toEqual([]);
|
|
58
|
+
expect(sepia.isolateAlpha).toBeCloseTo(0.7, 5);
|
|
59
|
+
const invert = adjustmentCalls(adj([{ op: 'invert', value: 1 }]))[0];
|
|
60
|
+
expect(invert.method).toBe('negative');
|
|
61
|
+
expect(invert.isolateAlpha).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('order is preserved end-to-end', () => {
|
|
65
|
+
const calls = adjustmentCalls(
|
|
66
|
+
adj([
|
|
67
|
+
{ op: 'brightness', value: 0.2 },
|
|
68
|
+
{ op: 'contrast', value: 0.1 },
|
|
69
|
+
{ op: 'sepia', value: 0.5 },
|
|
70
|
+
{ op: 'invert', value: 0.3 },
|
|
71
|
+
])
|
|
72
|
+
);
|
|
73
|
+
expect(calls.map((c) => c.method)).toEqual(['brightness', 'contrast', 'sepia', 'negative']);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('resolveSourceUrl — background-removal cutout replaces original', () => {
|
|
78
|
+
const SRC = 'assets/aaaa1111.jpg';
|
|
79
|
+
const MATTE = 'assets/bbbb2222.png';
|
|
80
|
+
test('unedited → original source', () => {
|
|
81
|
+
expect(resolveSourceUrl(null, SRC)).toBe(SRC);
|
|
82
|
+
expect(resolveSourceUrl({}, SRC)).toBe(SRC);
|
|
83
|
+
});
|
|
84
|
+
test('bg-removed enabled with a matte → the matte', () => {
|
|
85
|
+
expect(resolveSourceUrl({ backgroundRemoved: { enabled: true, maskAsset: MATTE } }, SRC)).toBe(
|
|
86
|
+
MATTE
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
test('bg-removed disabled OR no matte → original (non-destructive toggle)', () => {
|
|
90
|
+
expect(resolveSourceUrl({ backgroundRemoved: { enabled: false, maskAsset: MATTE } }, SRC)).toBe(
|
|
91
|
+
SRC
|
|
92
|
+
);
|
|
93
|
+
expect(resolveSourceUrl({ backgroundRemoved: { enabled: true } }, SRC)).toBe(SRC);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('needsCompositor — lazy-bundle gate', () => {
|
|
98
|
+
test('mirrors isDefaultEdit inverse', () => {
|
|
99
|
+
expect(needsCompositor(null)).toBe(false);
|
|
100
|
+
expect(needsCompositor({})).toBe(false);
|
|
101
|
+
expect(needsCompositor({ adjustments: { contrast: 0.4 } })).toBe(true);
|
|
102
|
+
expect(
|
|
103
|
+
needsCompositor({ backgroundRemoved: { enabled: true, maskAsset: 'assets/cc33.png' } })
|
|
104
|
+
).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// photo-taxonomy.test.ts — regression guard for the DDR-115 "three lists must
|
|
2
|
+
// agree" invariant, extended to the new `assets/<sha8>.photo.json` PhotoEdit
|
|
3
|
+
// sidecar (feature-photo-editor, Task 3).
|
|
4
|
+
//
|
|
5
|
+
// The invariant: a VERSIONED path must be classified VERSIONED by ALL THREE
|
|
6
|
+
// lists that encode the taxonomy —
|
|
7
|
+
// (1) apps/studio/git/service.ts → `isMaudeRuntimeState` (panel backstop),
|
|
8
|
+
// (2) cli/lib/gitignore-block.mjs → `buildBlock` (the `maude init` template),
|
|
9
|
+
// (3) the repo root `.gitignore` → tested via `git check-ignore` ground truth.
|
|
10
|
+
// `assets/**` (binaries AND `.photo.json` sidecars) is VERSIONED, so none of the
|
|
11
|
+
// three may hide it. This is the automated form of DDR-115 §3's manual claim.
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
import { buildBlock } from '../../../cli/lib/gitignore-block.mjs';
|
|
17
|
+
import { __testing } from '../git/service.ts';
|
|
18
|
+
|
|
19
|
+
const { isMaudeRuntimeState } = __testing;
|
|
20
|
+
|
|
21
|
+
// Representative content-addressed paths under `assets/`.
|
|
22
|
+
const PHOTO_SIDECAR_REL = 'assets/ab12cd34.photo.json';
|
|
23
|
+
const PHOTO_SIDECAR_DESIGN = '.design/assets/ab12cd34.photo.json';
|
|
24
|
+
const ASSET_BINARY_DESIGN = '.design/assets/ab12cd34.png';
|
|
25
|
+
const MATTE_ASSET_DESIGN = '.design/assets/ff00aa99.png';
|
|
26
|
+
|
|
27
|
+
describe('DDR-115 taxonomy — assets/<sha8>.photo.json is VERSIONED', () => {
|
|
28
|
+
test('isMaudeRuntimeState does NOT classify photo sidecars/assets as runtime', () => {
|
|
29
|
+
expect(isMaudeRuntimeState(PHOTO_SIDECAR_REL)).toBe(false);
|
|
30
|
+
expect(isMaudeRuntimeState(PHOTO_SIDECAR_DESIGN)).toBe(false);
|
|
31
|
+
expect(isMaudeRuntimeState(ASSET_BINARY_DESIGN)).toBe(false);
|
|
32
|
+
expect(isMaudeRuntimeState(MATTE_ASSET_DESIGN)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Positive control — proves the classifier's mechanism actually fires, so the
|
|
36
|
+
// negatives above aren't passing for a trivial reason (e.g. a broken regex).
|
|
37
|
+
test('isMaudeRuntimeState still classifies real runtime state as IGNORED', () => {
|
|
38
|
+
expect(isMaudeRuntimeState('.design/_server.json')).toBe(true);
|
|
39
|
+
expect(isMaudeRuntimeState('.design/_history/foo/bar.png')).toBe(true);
|
|
40
|
+
expect(isMaudeRuntimeState('.design/_canvas-state/x.view.json')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// `_photo/` (Task 18/20's headless bg-remove proof canvases, mirroring
|
|
44
|
+
// `_draw/`'s draw-proof canvases) must agree as IGNORED across all three lists.
|
|
45
|
+
test('_photo/ (headless bg-remove proof canvases) is IGNORED across all three lists', () => {
|
|
46
|
+
expect(isMaudeRuntimeState('.design/_photo/smoke-tsx.bgremove.tsx')).toBe(true);
|
|
47
|
+
|
|
48
|
+
const block = buildBlock('.design');
|
|
49
|
+
expect(block).toContain('.design/_photo/');
|
|
50
|
+
|
|
51
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
52
|
+
if (top.status !== 0) {
|
|
53
|
+
console.warn('git rev-parse failed; skipping root .gitignore ground-truth check');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const root = top.stdout.trim();
|
|
57
|
+
const r = spawnSync('git', ['check-ignore', '-q', '--', '.design/_photo/x.bgremove.tsx'], {
|
|
58
|
+
cwd: root,
|
|
59
|
+
});
|
|
60
|
+
expect(r.status).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the gitignore-block template ignores no assets/ path', () => {
|
|
64
|
+
for (const root of ['.design', 'design', 'mock']) {
|
|
65
|
+
const block = buildBlock(root);
|
|
66
|
+
// buildBlock only lists `${root}/_*` runtime prefixes — VERSIONED content
|
|
67
|
+
// (assets, canvases, meta) is deliberately absent. A future editor adding
|
|
68
|
+
// an `assets`/`.photo.json` ignore line trips this.
|
|
69
|
+
expect(block).not.toContain('assets');
|
|
70
|
+
expect(block).not.toContain('.photo.json');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('root .gitignore (git check-ignore ground truth) does not ignore photo assets', () => {
|
|
75
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
76
|
+
if (top.status !== 0) {
|
|
77
|
+
// Not in a git checkout (unexpected in CI/dev) — skip rather than false-fail.
|
|
78
|
+
console.warn('git rev-parse failed; skipping root .gitignore ground-truth check');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const root = top.stdout.trim();
|
|
82
|
+
|
|
83
|
+
const isIgnored = (relPath: string): boolean => {
|
|
84
|
+
const r = spawnSync('git', ['check-ignore', '-q', '--', relPath], { cwd: root });
|
|
85
|
+
// exit 0 = ignored, 1 = not ignored, other = error.
|
|
86
|
+
return r.status === 0;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// VERSIONED assets must NOT be ignored by the repo's own .gitignore.
|
|
90
|
+
expect(isIgnored(PHOTO_SIDECAR_DESIGN)).toBe(false);
|
|
91
|
+
expect(isIgnored(ASSET_BINARY_DESIGN)).toBe(false);
|
|
92
|
+
|
|
93
|
+
// Positive control — a known runtime path IS ignored, proving check-ignore works here.
|
|
94
|
+
expect(isIgnored('.design/_server.json')).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|