@1agh/maude 0.42.0 → 0.44.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/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +1451 -324
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +568 -24
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- 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 +258 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +656 -12
- package/apps/studio/canvas-lib.tsx +637 -10
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +2385 -699
- package/apps/studio/client/export-center.jsx +35 -1
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +18 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- 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 +432 -0
- package/apps/studio/client/styles/3-shell-maude.css +376 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +42 -8
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +5572 -21
- 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/draw/palette.ts +34 -0
- package/apps/studio/exporters/jobs.ts +47 -5
- package/apps/studio/exporters/video.ts +24 -4
- package/apps/studio/footage/schema.test.ts +287 -0
- package/apps/studio/footage/schema.ts +662 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +199 -21
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +1039 -22
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +20 -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/readiness.ts +108 -20
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +18 -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/_helpers.ts +15 -2
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- 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 +50 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -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 +154 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/generate-route.test.ts +106 -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/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- 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/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -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/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -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/tour-overlay.test.tsx +16 -0
- 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 +49 -21
- package/apps/studio/ui-prefs.ts +130 -0
- 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 +40 -1
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +149 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +67 -3
- package/cli/lib/gitignore-block.mjs +2 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +13 -11
- package/plugins/design/dependencies.json +18 -0
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// media-commit-chain — the Phase-23 batch-drop no-loss guarantee, isolated
|
|
2
|
+
// from React/DOM/real image decode timing so the race is testable
|
|
3
|
+
// deterministically (no real timers — every "completion" is a microtask-tick
|
|
4
|
+
// count, so the interleaving is exact and reproducible on every run).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { createMediaCommitChain } from '../media-commit-chain.ts';
|
|
9
|
+
|
|
10
|
+
interface Item {
|
|
11
|
+
id: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const keyOf = (it: Item) => it.id;
|
|
15
|
+
|
|
16
|
+
/** Resolve after exactly `n` microtask ticks (0 = next tick). */
|
|
17
|
+
function afterTicks(n: number): Promise<void> {
|
|
18
|
+
let p: Promise<void> = Promise.resolve();
|
|
19
|
+
for (let i = 0; i < n; i++) p = p.then(() => undefined);
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('media-commit-chain / no-loss under concurrent completions', () => {
|
|
24
|
+
test('a synchronous burst of N enqueues (worst case — zero timing gap) keeps all N', async () => {
|
|
25
|
+
let snapshot: Item[] = [];
|
|
26
|
+
const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
|
|
27
|
+
const committed: Item[][] = [];
|
|
28
|
+
const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
|
|
29
|
+
committed.push(after as Item[]);
|
|
30
|
+
snapshot = after as Item[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// All 8 fire back-to-back in the same synchronous tick — the exact shape
|
|
34
|
+
// of use-canvas-media-drop's batch-dispatch loop calling onImage/onMedia
|
|
35
|
+
// once per file, each kicking off its own independent async chain that
|
|
36
|
+
// (in the old, buggy code) raced on a shared strokesRef.current read.
|
|
37
|
+
const promises = Array.from({ length: 8 }, (_, i) =>
|
|
38
|
+
chain.enqueue(
|
|
39
|
+
(before) => ({ after: [...before, { id: `item-${i}` }], label: `add ${i}` }),
|
|
40
|
+
onCommit
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
await Promise.all(promises);
|
|
44
|
+
|
|
45
|
+
const final = committed[committed.length - 1];
|
|
46
|
+
expect(final.length).toBe(8);
|
|
47
|
+
expect(new Set(final.map((it) => it.id)).size).toBe(8);
|
|
48
|
+
for (let i = 0; i < 8; i++) {
|
|
49
|
+
expect(final.some((it) => it.id === `item-${i}`)).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('staggered/shuffled resolution order still accumulates every item exactly once', async () => {
|
|
54
|
+
let snapshot: Item[] = [];
|
|
55
|
+
const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
|
|
56
|
+
const committed: Item[][] = [];
|
|
57
|
+
const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
|
|
58
|
+
committed.push(after as Item[]);
|
|
59
|
+
snapshot = after as Item[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// A fixed (non-random, so non-flaky) permutation of tick-depths — some
|
|
63
|
+
// items resolve in the same tick as others, some land in between,
|
|
64
|
+
// deliberately out of index order (index 4 resolves before index 0).
|
|
65
|
+
const tickCounts = [4, 0, 6, 2, 7, 1, 5, 3];
|
|
66
|
+
const promises = tickCounts.map((ticks, i) =>
|
|
67
|
+
afterTicks(ticks).then(() =>
|
|
68
|
+
chain.enqueue(
|
|
69
|
+
(before) => ({ after: [...before, { id: `item-${i}` }], label: `add ${i}` }),
|
|
70
|
+
onCommit
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
await Promise.all(promises);
|
|
75
|
+
|
|
76
|
+
const final = committed[committed.length - 1];
|
|
77
|
+
expect(final.length).toBe(8);
|
|
78
|
+
expect(new Set(final.map((it) => it.id)).size).toBe(8);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('skip (mutate returns null) leaves the accumulator untouched', async () => {
|
|
82
|
+
let snapshot: Item[] = [];
|
|
83
|
+
const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
|
|
84
|
+
const committed: Item[][] = [];
|
|
85
|
+
const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
|
|
86
|
+
committed.push(after as Item[]);
|
|
87
|
+
snapshot = after as Item[];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
await chain.enqueue((before) => ({ after: [...before, { id: 'a' }] }), onCommit);
|
|
91
|
+
const skipped = await chain.enqueue(() => null, onCommit);
|
|
92
|
+
expect(skipped.map((it) => it.id)).toEqual(['a']);
|
|
93
|
+
expect(committed.length).toBe(1); // the skipped mutation never called onCommit
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a lagging snapshot (render has not caught up yet) does not lose the prior commit', async () => {
|
|
97
|
+
// This is the regression caught live against a real dev server (Task 5):
|
|
98
|
+
// getSnapshot() (strokesRef.current in production) only catches up on
|
|
99
|
+
// React's NEXT render, which can take longer than the handful of
|
|
100
|
+
// microtask ticks this chain itself needs to settle a link and go idle.
|
|
101
|
+
// `rendered` here deliberately never updates on its own — it models a
|
|
102
|
+
// render that hasn't happened yet by the time the second file's upload
|
|
103
|
+
// resolves and enqueues.
|
|
104
|
+
const rendered: Item[] = [];
|
|
105
|
+
const chain = createMediaCommitChain<Item>(() => rendered, keyOf);
|
|
106
|
+
const commits: Array<{ after: readonly Item[] }> = [];
|
|
107
|
+
const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
|
|
108
|
+
commits.push({ after });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await chain.enqueue((before) => ({ after: [...before, { id: 'video' }] }), onCommit);
|
|
112
|
+
// The chain is idle now (its one link has settled) — `rendered` is still
|
|
113
|
+
// [] (no render happened). The next enqueue must reconcile against what
|
|
114
|
+
// THIS chain last produced, not trust the stale empty snapshot.
|
|
115
|
+
const after = await chain.enqueue(
|
|
116
|
+
(before) => ({ after: [...before, { id: 'audio' }] }),
|
|
117
|
+
onCommit
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(after.map((it) => it.id).sort()).toEqual(['audio', 'video']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('external edits made while idle (snapshot ahead of remembered) are preserved', async () => {
|
|
124
|
+
let snapshot: Item[] = [{ id: 'external' }];
|
|
125
|
+
const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
|
|
126
|
+
const commits: Array<{ before: readonly Item[]; after: readonly Item[] }> = [];
|
|
127
|
+
const onCommit = (before: readonly Item[], after: readonly Item[]) => {
|
|
128
|
+
commits.push({ before, after });
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await chain.enqueue((before) => ({ after: [...before, { id: 'a' }] }), onCommit);
|
|
132
|
+
// Simulate a synchronous, non-chained edit that landed while idle (e.g. a
|
|
133
|
+
// pen stroke committed directly, its render already caught up) — the
|
|
134
|
+
// next chain link must see it via the fresh snapshot.
|
|
135
|
+
snapshot = [...snapshot, { id: 'a' }, { id: 'pen-stroke' }];
|
|
136
|
+
await chain.enqueue((before) => ({ after: [...before, { id: 'b' }] }), onCommit);
|
|
137
|
+
|
|
138
|
+
const last = commits[commits.length - 1];
|
|
139
|
+
expect(last.after.some((it) => it.id === 'pen-stroke')).toBe(true);
|
|
140
|
+
expect(last.after.some((it) => it.id === 'b')).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a snapshot with a STALE VALUE for a chain-owned id (not just a missing one) does not revert it', async () => {
|
|
144
|
+
// The real bug this closes: `fresh` (getSnapshot) can contain the SAME
|
|
145
|
+
// id the chain just committed, but with a stale value — e.g. an image
|
|
146
|
+
// stroke's optimistic (blob:) href is set via a separate, non-chain
|
|
147
|
+
// setState, and `fresh` catches up to THAT before it catches up to the
|
|
148
|
+
// chain's own later commit swapping it to the real href. Checking only
|
|
149
|
+
// "is the id present" (the previous implementation) treated this as
|
|
150
|
+
// "already rendered, trust fresh" and used the stale copy — silently
|
|
151
|
+
// reverting the swap, which then gets filtered out entirely as
|
|
152
|
+
// still-ephemeral at the persistence layer one level up. Confirmed live
|
|
153
|
+
// against a real dev server: a single 3-file drop lost exactly one file
|
|
154
|
+
// most of the time, always the one whose swap commit landed while
|
|
155
|
+
// `fresh` still only reflected its pre-swap optimistic value.
|
|
156
|
+
const snapshot: Item[] = [{ id: 'img-optimistic', href: 'blob:x' } as unknown as Item];
|
|
157
|
+
const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
|
|
158
|
+
const commits: Array<{ after: readonly Item[] }> = [];
|
|
159
|
+
const onCommit = (_before: readonly Item[], after: readonly Item[]) => {
|
|
160
|
+
commits.push({ after });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// The chain's own commit swaps the id to its real value...
|
|
164
|
+
await chain.enqueue(
|
|
165
|
+
(before) => ({
|
|
166
|
+
after: before.map((it) =>
|
|
167
|
+
it.id === 'img-optimistic' ? { ...it, href: 'assets/real.png' } : it
|
|
168
|
+
),
|
|
169
|
+
}),
|
|
170
|
+
onCommit
|
|
171
|
+
);
|
|
172
|
+
// ...but `snapshot` (fresh) hasn't re-rendered to reflect that swap yet
|
|
173
|
+
// — it still shows the SAME id with the stale, optimistic href. This
|
|
174
|
+
// models the render lag, not a delete/re-add.
|
|
175
|
+
|
|
176
|
+
const after = await chain.enqueue(
|
|
177
|
+
(before) => ({ after: [...before, { id: 'sibling' }] }),
|
|
178
|
+
onCommit
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const img = after.find((it) => it.id === 'img-optimistic') as unknown as { href: string };
|
|
182
|
+
expect(img?.href).toBe('assets/real.png');
|
|
183
|
+
expect(after.some((it) => it.id === 'sibling')).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('commitBefore overrides the before passed to onCommit without affecting the accumulator', async () => {
|
|
187
|
+
const snapshot: Item[] = [{ id: 'optimistic' }];
|
|
188
|
+
const chain = createMediaCommitChain<Item>(() => snapshot, keyOf);
|
|
189
|
+
const commits: Array<{ before: readonly Item[]; after: readonly Item[] }> = [];
|
|
190
|
+
const onCommit = (before: readonly Item[], after: readonly Item[]) => {
|
|
191
|
+
commits.push({ before, after });
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Mirrors createImageFromFile's swap: "before" (accumulator) still
|
|
195
|
+
// contains the ephemeral optimistic entry, but the undo record's
|
|
196
|
+
// `before` should exclude it (undo removes the image outright instead
|
|
197
|
+
// of restoring a revoked blob: URL).
|
|
198
|
+
const after = await chain.enqueue(
|
|
199
|
+
(before) => ({
|
|
200
|
+
after: before.map((it) => (it.id === 'optimistic' ? { id: 'real' } : it)),
|
|
201
|
+
commitBefore: before.filter((it) => it.id !== 'optimistic'),
|
|
202
|
+
}),
|
|
203
|
+
onCommit
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(after).toEqual([{ id: 'real' }]);
|
|
207
|
+
expect(commits[0].before).toEqual([]);
|
|
208
|
+
expect(commits[0].after).toEqual([{ id: 'real' }]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// photo-bg-remove.sh --asset validation — fix-photo-editor-followup-debt Task
|
|
2
|
+
// 21. Drives the real bash script as a subprocess (mirrors annotate-write.test.ts's
|
|
3
|
+
// spawn-a-bin pattern) against a temp design root, asserting the strict
|
|
4
|
+
// `assets/<sha8>.<ext>` shape check (Task 1) rejects path traversal and
|
|
5
|
+
// injection-shaped values with exit 2, while a well-formed reference proceeds
|
|
6
|
+
// PAST validation (a missing on-disk asset then fails for a different, later
|
|
7
|
+
// reason — exit 1 — proving the regex, not the filesystem check, isn't what
|
|
8
|
+
// stopped it).
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
11
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const BIN = new URL('../bin/photo-bg-remove.sh', import.meta.url).pathname;
|
|
16
|
+
|
|
17
|
+
let root: string;
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
root = mkdtempSync(join(tmpdir(), 'photo-bg-remove-test-'));
|
|
21
|
+
mkdirSync(join(root, '.design'), { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
rmSync(root, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function run(asset: string): { code: number; err: string } {
|
|
29
|
+
const proc = Bun.spawnSync(['bash', BIN, '--asset', asset, '--root', root]);
|
|
30
|
+
return {
|
|
31
|
+
code: proc.exitCode,
|
|
32
|
+
err: new TextDecoder().decode(proc.stderr),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('photo-bg-remove.sh --asset validation', () => {
|
|
37
|
+
test('rejects path traversal', () => {
|
|
38
|
+
const res = run('assets/../../../etc/passwd');
|
|
39
|
+
expect(res.code).toBe(2);
|
|
40
|
+
expect(res.err).toContain('must look like assets/<sha8>.<ext>');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('rejects an injection-shaped value (quotes/braces breaking out of the JSX splice)', () => {
|
|
44
|
+
const res = run('assets/aaaaaaaa.png" }; alert(1); ({ "y.png');
|
|
45
|
+
expect(res.code).toBe(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('rejects backtick-embedded values', () => {
|
|
49
|
+
const res = run('assets/aaaaaaaa$(whoami).png');
|
|
50
|
+
expect(res.code).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('rejects uppercase hex (sha8 is always lowercase in this codebase)', () => {
|
|
54
|
+
const res = run('assets/DEADBEEF.png');
|
|
55
|
+
expect(res.code).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('rejects a bare prefix with no hex body', () => {
|
|
59
|
+
const res = run('assets/');
|
|
60
|
+
expect(res.code).toBe(2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a well-formed reference proceeds past validation (fails later, not with exit 2)', () => {
|
|
64
|
+
const res = run('assets/deadbeef.png');
|
|
65
|
+
expect(res.code).not.toBe(2);
|
|
66
|
+
// No asset on disk under this temp root — fails at the existence check
|
|
67
|
+
// (exit 1), proving the regex accepted the shape and let it through.
|
|
68
|
+
expect(res.code).toBe(1);
|
|
69
|
+
expect(res.err).toContain('asset not found');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -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
|
+
});
|