@dfosco/storyboard 0.7.1 → 0.9.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/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +14128 -10953
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/dist/tiny-canvas.css +1 -1
- package/package.json +138 -128
- package/scaffold/AGENTS.md +9 -7
- package/scaffold/README.md +18 -0
- package/scaffold/deploy.yml +21 -0
- package/scaffold/gitignore +8 -14
- package/scaffold/preview.yml +40 -0
- package/scaffold/{manifest.json → scaffold.config.json} +73 -3
- package/scaffold/scripts/aggregate-artifacts.mjs +257 -0
- package/scaffold/skills/canvas/SKILL.md +1 -1
- package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
- package/scaffold/skills/ship/SKILL.md +2 -2
- package/scaffold/skills/worktree/SKILL.md +1 -1
- package/scaffold/src/_prototype.jsx +17 -0
- package/scaffold/src/_story.jsx +13 -0
- package/scaffold/src/library/README.md +34 -0
- package/scaffold/src/library/_app.jsx +41 -0
- package/scaffold/src/library/_app.module.css +2 -0
- package/scaffold/src/library/create.jsx +10 -0
- package/scaffold/src/library/index.jsx +14 -0
- package/scaffold/src/library/mount.jsx +51 -0
- package/scaffold/src/library/prototypes-entry.jsx +57 -0
- package/scaffold/src/library/routes.jsx +205 -0
- package/scaffold/src/library/viewfinder.jsx +13 -0
- package/scaffold/src/library/workspace.jsx +17 -0
- package/scripts/aggregate-artifacts.mjs +257 -0
- package/src/canvas/style.css +16 -16
- package/src/core/artifact/operations.js +54 -15
- package/src/core/artifact/schemas/canvas.schema.json +7 -1
- package/src/core/autosync/server.test.js +1 -1
- package/src/core/canvas/hot-pool.js +21 -33
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/selectedWidgets.js +1 -1
- package/src/core/canvas/server.js +376 -100
- package/src/core/canvas/server.test.js +472 -2
- package/src/core/canvas/terminal-server.js +144 -57
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/artifact.js +2 -0
- package/src/core/cli/buildManifest.js +34 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/create.js +151 -23
- package/src/core/cli/dev.js +23 -35
- package/src/core/cli/index.js +21 -0
- package/src/core/cli/schemas.js +8 -4
- package/src/core/cli/serverUrl.js +24 -0
- package/src/core/cli/setup.js +14 -25
- package/src/core/cli/terminal-welcome.js +12 -6
- package/src/core/cli/updateVersion.js +42 -2
- package/src/core/cli/workleaf.js +140 -0
- package/src/core/comments/api.js +1 -1
- package/src/core/comments/queries.js +3 -0
- package/src/core/comments/ui/CommentWindow.jsx +3 -2
- package/src/core/comments/ui/comment-layout.css +14 -14
- package/src/core/data/artifactIndex.js +235 -0
- package/src/core/data/artifactIndex.test.js +203 -0
- package/src/core/data/artifactManifest.js +290 -0
- package/src/core/data/artifactManifest.test.js +207 -0
- package/src/core/data/viewfinder.js +47 -7
- package/src/core/index.js +24 -1
- package/src/core/inspector/highlighter.js +1 -1
- package/src/core/inspector/mouseMode.js +2 -2
- package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
- package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
- package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
- package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
- package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
- package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
- package/src/core/lib/components/ui/dialog/index.js +4 -34
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
- package/src/core/lib/components/ui/label/label.jsx +1 -2
- package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
- package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
- package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
- package/src/core/lib/components/ui/panel/panel.jsx +11 -2
- package/src/core/lib/components/ui/separator/separator.jsx +6 -4
- package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
- package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
- package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
- package/src/core/modes/modes.css +4 -4
- package/src/core/mountStoryboardCore.js +287 -83
- package/src/core/mountStoryboardCore.test.js +198 -0
- package/src/core/rename-watcher/config.json +1 -1
- package/src/core/rename-watcher/watcher.js +330 -37
- package/src/core/rename-watcher/watcher.test.js +253 -0
- package/src/core/scaffold/fragments.js +287 -0
- package/src/core/scaffold/fragments.test.js +401 -0
- package/src/core/scaffold/index.js +291 -0
- package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
- package/src/core/scaffold/scaffold-integration.test.js +247 -0
- package/src/core/scaffold.js +4 -97
- package/src/core/stores/canvasInteractionStore.js +120 -0
- package/src/core/stores/canvasInteractionStore.test.js +126 -0
- package/src/core/stores/configSchema.js +64 -8
- package/src/core/stores/configSchema.test.js +3 -0
- package/src/core/stores/customerModeConfig.js +24 -3
- package/src/core/stores/customerModeConfig.test.js +46 -0
- package/src/core/stores/presentationStore.js +118 -0
- package/src/core/stores/presentationStore.test.js +114 -0
- package/src/core/stores/recentArtifacts.js +49 -2
- package/src/core/stores/recentArtifacts.test.js +59 -6
- package/src/core/stores/themeBootstrap.js +264 -0
- package/src/core/stores/themeBootstrap.test.js +211 -0
- package/src/core/stores/themeStore.test.js +154 -0
- package/src/core/stores/themeStore.ts +235 -220
- package/src/core/stores/uiConfig.js +10 -2
- package/src/core/styles/generateThemesCss.js +63 -0
- package/src/core/styles/tailwind.css +150 -94
- package/src/core/styles/workspace-chrome.css +13 -0
- package/src/core/tools/handlers/paletteTheme.js +11 -8
- package/src/core/ui/ActionMenuButton.jsx +2 -1
- package/src/core/ui/AutosyncMenuButton.css +7 -7
- package/src/core/ui/BranchSelect.module.css +5 -5
- package/src/core/ui/CanvasCreateMenu.jsx +1 -19
- package/src/core/ui/ChromeSlot.jsx +92 -0
- package/src/core/ui/ChromeSlot.test.jsx +140 -0
- package/src/core/ui/CoreUIBar.css +5 -5
- package/src/core/ui/CoreUIBar.jsx +170 -142
- package/src/core/ui/InspectorPanel.css +5 -5
- package/src/core/ui/InspectorPanel.jsx +9 -9
- package/src/core/ui/PwaInstallBanner.css +5 -5
- package/src/core/ui/PwaInstallBanner.jsx +5 -5
- package/src/core/ui/ThemeMenuButton.jsx +45 -30
- package/src/core/ui/core-ui-colors.css +28 -66
- package/src/core/ui/sidepanel.css +12 -12
- package/src/core/vite/server-plugin.js +13 -0
- package/src/core/workleaf/cleanup.js +102 -0
- package/src/core/workleaf/create.js +199 -0
- package/src/core/workleaf/delete.js +101 -0
- package/src/core/workleaf/list.js +57 -0
- package/src/core/workleaf/merge.js +238 -0
- package/src/core/workleaf/paths.js +146 -0
- package/src/core/workleaf/paths.test.js +230 -0
- package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +130 -34
- package/src/core/workshop/features/createFlow/server.js +5 -1
- package/src/core/workshop/features/createPrototype/server.js +5 -1
- package/src/core/workshop/features/templateIndex.js +28 -1
- package/src/core/worktree/port.js +48 -5
- package/src/core/worktree/port.test.js +42 -0
- package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
- package/src/internals/ArtifactForm/ArtifactForm.module.css +51 -69
- package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
- package/src/internals/AuthModal/AuthModal.module.css +34 -34
- package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
- package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
- package/src/internals/BranchBar/BranchBar.module.css +4 -4
- package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
- package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
- package/src/internals/CommandPalette/CommandPalette.jsx +41 -29
- package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
- package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
- package/src/internals/CommandPalette/WidgetArtifactDialog.css +16 -16
- package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
- package/src/internals/CommandPalette/command-palette.css +14 -14
- package/src/internals/CommandPalette/createHelpers.js +30 -0
- package/src/internals/CreatePage/CreatePage.jsx +57 -0
- package/src/internals/CreatePage/CreatePage.module.css +34 -0
- package/src/internals/Dialog.jsx +51 -0
- package/src/internals/FlowError.module.css +5 -5
- package/src/internals/PrototypeErrorBoundary.module.css +15 -15
- package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +534 -0
- package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
- package/src/internals/Tooltip.jsx +39 -0
- package/src/internals/Viewfinder.jsx +229 -64
- package/src/internals/Viewfinder.module.css +253 -182
- package/src/internals/canvas/CanvasControls.module.css +11 -11
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +11 -37
- package/src/internals/canvas/CanvasPage.jsx +358 -217
- package/src/internals/canvas/CanvasPage.module.css +20 -19
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/CanvasToolbar.module.css +11 -11
- package/src/internals/canvas/ConnectorLayer.jsx +39 -2
- package/src/internals/canvas/ConnectorLayer.module.css +10 -10
- package/src/internals/canvas/PageSelector.jsx +15 -1
- package/src/internals/canvas/PageSelector.module.css +41 -27
- package/src/internals/canvas/canvasApi.js +40 -5
- package/src/internals/canvas/canvasTheme.js +22 -111
- package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
- package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
- package/src/internals/canvas/isolation/prototypeRoutes.jsx +140 -0
- package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
- package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
- package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/canvas/widgets/CodePenEmbed.jsx +16 -36
- package/src/internals/canvas/widgets/CodePenEmbed.module.css +12 -12
- package/src/internals/canvas/widgets/CropOverlay.module.css +23 -23
- package/src/internals/canvas/widgets/ExpandedPane.module.css +8 -8
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
- package/src/internals/canvas/widgets/FigmaEmbed.jsx +15 -36
- package/src/internals/canvas/widgets/FigmaEmbed.module.css +15 -15
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +5 -5
- package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
- package/src/internals/canvas/widgets/ImageWidget.module.css +3 -3
- package/src/internals/canvas/widgets/LinkPreview.module.css +79 -80
- package/src/internals/canvas/widgets/MarkdownBlock.module.css +31 -31
- package/src/internals/canvas/widgets/PromptWidget.jsx +2 -16
- package/src/internals/canvas/widgets/PromptWidget.module.css +33 -33
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +92 -17
- package/src/internals/canvas/widgets/PrototypeEmbed.module.css +58 -56
- package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +118 -4
- package/src/internals/canvas/widgets/ResizeHandle.module.css +4 -4
- package/src/internals/canvas/widgets/StorySetWidget.jsx +53 -25
- package/src/internals/canvas/widgets/StoryWidget.jsx +71 -56
- package/src/internals/canvas/widgets/StoryWidget.module.css +22 -20
- package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
- package/src/internals/canvas/widgets/TerminalReadWidget.module.css +4 -4
- package/src/internals/canvas/widgets/TerminalWidget.jsx +56 -45
- package/src/internals/canvas/widgets/TerminalWidget.module.css +10 -10
- package/src/internals/canvas/widgets/TilesWidget.module.css +17 -17
- package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
- package/src/internals/canvas/widgets/WidgetChrome.module.css +76 -61
- package/src/internals/canvas/widgets/WidgetWrapper.module.css +2 -2
- package/src/internals/canvas/widgets/embedOverlay.module.css +14 -4
- package/src/internals/canvas/widgets/embedTheme.js +12 -104
- package/src/internals/canvas/widgets/expandUtils.js +19 -19
- package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
- package/src/internals/canvas/widgets/widgetConfig.js +5 -5
- package/src/internals/context.jsx +22 -1
- package/src/internals/context.test.jsx +47 -0
- package/src/internals/hooks/useArtifactIndex.js +31 -0
- package/src/internals/hooks/useArtifactIndex.test.js +45 -0
- package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
- package/src/internals/index.js +6 -0
- package/src/internals/loadConsumerWrapper.js +60 -0
- package/src/internals/story/ComponentSetPage.jsx +75 -34
- package/src/internals/story/ComponentSetPage.module.css +14 -14
- package/src/internals/story/StoryPage.jsx +16 -25
- package/src/internals/story/StoryPage.module.css +2 -2
- package/src/internals/vite/data-plugin.js +317 -149
- package/src/internals/vite/data-plugin.test.js +11 -11
- package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
- package/terminal.config.json +1 -1
- package/src/core/lib/components/ui/card/card-action.jsx +0 -11
- package/src/core/lib/components/ui/card/card-content.jsx +0 -11
- package/src/core/lib/components/ui/card/card-description.jsx +0 -11
- package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
- package/src/core/lib/components/ui/card/card-header.jsx +0 -19
- package/src/core/lib/components/ui/card/card-title.jsx +0 -11
- package/src/core/lib/components/ui/card/card.jsx +0 -17
- package/src/core/lib/components/ui/card/index.js +0 -25
- package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/index.js +0 -13
- package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
- package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
- package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
- package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
- package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
- package/src/core/lib/components/ui/popover/index.js +0 -28
- package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
- package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
- package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
- package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
- package/src/core/lib/components/ui/popover/popover.jsx +0 -4
- package/src/core/lib/components/ui/select/index.js +0 -37
- package/src/core/lib/components/ui/select/select-content.jsx +0 -30
- package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
- package/src/core/lib/components/ui/select/select-group.jsx +0 -15
- package/src/core/lib/components/ui/select/select-item.jsx +0 -26
- package/src/core/lib/components/ui/select/select-label.jsx +0 -11
- package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
- package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
- package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
- package/src/core/lib/components/ui/select/select.jsx +0 -4
- package/src/core/lib/components/ui/sheet/index.js +0 -34
- package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
- package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
- package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
- package/src/core/lib/components/ui/toggle/index.js +0 -8
- package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
- package/src/core/lib/components/ui/toggle-group/index.js +0 -10
- package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
- package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
- package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
- package/src/internals/canvas/WebGLContextPool.jsx +0 -291
- package/src/internals/canvas/WebGLContextPool.test.jsx +0 -165
- package/src/internals/canvas/componentIsolate.jsx +0 -144
- package/src/internals/canvas/componentSetIsolate.jsx +0 -300
- package/src/internals/canvas/widgets/ComponentSetWidget.module.css +0 -89
- package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
- package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
- package/src/internals/canvas/widgets/StorySetWidget.module.css +0 -89
- package/src/primer/ThemeSync.jsx +0 -73
- package/src/primer/index.js +0 -1
|
@@ -14,7 +14,7 @@ function setup() {
|
|
|
14
14
|
const canvasDir = path.join(root, 'src', 'canvas')
|
|
15
15
|
fs.mkdirSync(canvasDir, { recursive: true })
|
|
16
16
|
|
|
17
|
-
const lastResponse = { status: null, body: null }
|
|
17
|
+
const lastResponse = { status: null, body: null, bytes: null, headers: null }
|
|
18
18
|
|
|
19
19
|
const ctx = {
|
|
20
20
|
root,
|
|
@@ -26,8 +26,24 @@ function setup() {
|
|
|
26
26
|
|
|
27
27
|
const handler = createCanvasHandler(ctx)
|
|
28
28
|
|
|
29
|
+
// Streaming mock for binary GET responses (image fetches). Records the
|
|
30
|
+
// bytes + headers so tests can assert without coupling to real http.
|
|
31
|
+
function makeRes() {
|
|
32
|
+
let buf = Buffer.alloc(0)
|
|
33
|
+
return {
|
|
34
|
+
writeHead(status, headers) {
|
|
35
|
+
lastResponse.status = status
|
|
36
|
+
lastResponse.headers = headers
|
|
37
|
+
},
|
|
38
|
+
end(data) {
|
|
39
|
+
if (data) buf = Buffer.concat([buf, Buffer.isBuffer(data) ? data : Buffer.from(data)])
|
|
40
|
+
lastResponse.bytes = buf
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
const invoke = (routePath, method, body = {}) =>
|
|
30
|
-
handler(null,
|
|
46
|
+
handler(null, makeRes(), { path: routePath, method, body })
|
|
31
47
|
|
|
32
48
|
return { invoke, root, canvasDir, lastResponse }
|
|
33
49
|
}
|
|
@@ -377,3 +393,457 @@ describe('POST /batch', () => {
|
|
|
377
393
|
expect(refs['1']).toMatch(/^connector-/)
|
|
378
394
|
})
|
|
379
395
|
})
|
|
396
|
+
|
|
397
|
+
describe('POST /undo and POST /redo (event-id targeting)', () => {
|
|
398
|
+
let root, canvasDir, invoke, lastResponse
|
|
399
|
+
|
|
400
|
+
beforeEach(() => {
|
|
401
|
+
({ invoke, root, canvasDir, lastResponse } = setup())
|
|
402
|
+
writeCanvas(canvasDir, 'undo-canvas', { title: 'Undo Canvas' })
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
afterEach(() => {
|
|
406
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
async function addSticky(text) {
|
|
410
|
+
await invoke('/widget', 'POST', {
|
|
411
|
+
name: 'undo-canvas',
|
|
412
|
+
type: 'sticky-note',
|
|
413
|
+
props: { text },
|
|
414
|
+
position: { x: 0, y: 0 },
|
|
415
|
+
})
|
|
416
|
+
return { widgetId: lastResponse.body.widget.id, eventId: lastResponse.body.eventId }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function readState() {
|
|
420
|
+
await invoke('/read?name=undo-canvas', 'GET', {})
|
|
421
|
+
return lastResponse.body
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
it('appends an event id on every mutation', async () => {
|
|
425
|
+
const { eventId } = await addSticky('a')
|
|
426
|
+
expect(eventId).toMatch(/^evt_/)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('reproduces the cq-enablement scenario: 3 deletes + undo restores only the most recent', async () => {
|
|
430
|
+
const a = await addSticky('a')
|
|
431
|
+
const b = await addSticky('b')
|
|
432
|
+
const c = await addSticky('c')
|
|
433
|
+
|
|
434
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: a.widgetId })
|
|
435
|
+
const delAEventId = lastResponse.body.eventId
|
|
436
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: b.widgetId })
|
|
437
|
+
const delBEventId = lastResponse.body.eventId
|
|
438
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: c.widgetId })
|
|
439
|
+
const delCEventId = lastResponse.body.eventId
|
|
440
|
+
|
|
441
|
+
expect((await readState()).widgets).toEqual([])
|
|
442
|
+
|
|
443
|
+
// Undo only the LAST delete (c).
|
|
444
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delCEventId })
|
|
445
|
+
expect(lastResponse.status).toBe(200)
|
|
446
|
+
expect(lastResponse.body.inverseEvent.event).toBe('widget_added')
|
|
447
|
+
expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'undo', of: delCEventId })
|
|
448
|
+
|
|
449
|
+
const after = await readState()
|
|
450
|
+
expect(after.widgets).toHaveLength(1)
|
|
451
|
+
expect(after.widgets[0].id).toBe(c.widgetId)
|
|
452
|
+
// a and b stay deleted — the bug class would have resurrected them too.
|
|
453
|
+
expect(after.widgets.map((w) => w.id)).not.toContain(a.widgetId)
|
|
454
|
+
expect(after.widgets.map((w) => w.id)).not.toContain(b.widgetId)
|
|
455
|
+
|
|
456
|
+
// Tracking that we don't leave delAEventId / delBEventId mentioned.
|
|
457
|
+
void delAEventId
|
|
458
|
+
void delBEventId
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('undo of widget_updated reverts the props using prevProps', async () => {
|
|
462
|
+
const { widgetId } = await addSticky('original')
|
|
463
|
+
|
|
464
|
+
await invoke('/widget', 'PATCH', {
|
|
465
|
+
name: 'undo-canvas',
|
|
466
|
+
widgetId,
|
|
467
|
+
props: { text: 'edited' },
|
|
468
|
+
})
|
|
469
|
+
const editEventId = lastResponse.body.eventIds[0]
|
|
470
|
+
|
|
471
|
+
let state = await readState()
|
|
472
|
+
expect(state.widgets[0].props.text).toBe('edited')
|
|
473
|
+
|
|
474
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: editEventId })
|
|
475
|
+
state = await readState()
|
|
476
|
+
expect(state.widgets[0].props.text).toBe('original')
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('undo of widget_moved reverts the position using prevPosition', async () => {
|
|
480
|
+
const { widgetId } = await addSticky('a')
|
|
481
|
+
|
|
482
|
+
await invoke('/widget', 'PATCH', {
|
|
483
|
+
name: 'undo-canvas',
|
|
484
|
+
widgetId,
|
|
485
|
+
position: { x: 500, y: 600 },
|
|
486
|
+
})
|
|
487
|
+
const moveEventId = lastResponse.body.eventIds[0]
|
|
488
|
+
|
|
489
|
+
let state = await readState()
|
|
490
|
+
expect(state.widgets[0].position).toEqual({ x: 500, y: 600 })
|
|
491
|
+
|
|
492
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: moveEventId })
|
|
493
|
+
state = await readState()
|
|
494
|
+
expect(state.widgets[0].position).toEqual({ x: 0, y: 0 })
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('redo of an undone delete re-removes the widget', async () => {
|
|
498
|
+
const { widgetId } = await addSticky('a')
|
|
499
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId })
|
|
500
|
+
const delEventId = lastResponse.body.eventId
|
|
501
|
+
|
|
502
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delEventId })
|
|
503
|
+
const undoEventId = lastResponse.body.eventId
|
|
504
|
+
expect((await readState()).widgets).toHaveLength(1)
|
|
505
|
+
|
|
506
|
+
// Redo with the undo event id — server emits the inverse of the undo,
|
|
507
|
+
// which is a fresh widget_removed.
|
|
508
|
+
await invoke('/redo', 'POST', { name: 'undo-canvas', eventId: undoEventId })
|
|
509
|
+
expect(lastResponse.body.inverseEvent.event).toBe('widget_removed')
|
|
510
|
+
expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'redo', of: undoEventId })
|
|
511
|
+
|
|
512
|
+
expect((await readState()).widgets).toEqual([])
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('rejects undo on an unknown event id with 404', async () => {
|
|
516
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: 'evt_does_not_exist' })
|
|
517
|
+
expect(lastResponse.status).toBe(404)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('rejects undo on a non-undoable event (widgets_replaced) with 400', async () => {
|
|
521
|
+
// Trigger a widgets_replaced via PUT /update with replaceAll
|
|
522
|
+
await invoke('/update', 'PUT', {
|
|
523
|
+
name: 'undo-canvas',
|
|
524
|
+
widgets: [],
|
|
525
|
+
replaceAll: true,
|
|
526
|
+
})
|
|
527
|
+
// Read the file to get the event id of the widgets_replaced event.
|
|
528
|
+
const filePath = path.join(canvasDir, 'undo-canvas.canvas.jsonl')
|
|
529
|
+
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
|
|
530
|
+
const replacedEvt = lines.find((l) => l.event === 'widgets_replaced')
|
|
531
|
+
expect(replacedEvt?.id).toBeTruthy()
|
|
532
|
+
|
|
533
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: replacedEvt.id })
|
|
534
|
+
expect(lastResponse.status).toBe(400)
|
|
535
|
+
expect(lastResponse.body.error).toMatch(/cannot be undone/i)
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
describe('runaway-session hard ceiling compaction', () => {
|
|
540
|
+
let root, canvasDir, invoke, lastResponse
|
|
541
|
+
|
|
542
|
+
beforeEach(() => {
|
|
543
|
+
({ invoke, root, canvasDir, lastResponse } = setup())
|
|
544
|
+
writeCanvas(canvasDir, 'big-canvas', { title: 'Big Canvas' })
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
afterEach(() => {
|
|
548
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
// setImmediate-based test helper
|
|
552
|
+
function flushSetImmediate() {
|
|
553
|
+
return new Promise((resolve) => setImmediate(resolve))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
it('does not compact a small file (under the 2 MB threshold)', async () => {
|
|
557
|
+
const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
|
|
558
|
+
await invoke('/widget', 'POST', {
|
|
559
|
+
name: 'big-canvas',
|
|
560
|
+
type: 'sticky-note',
|
|
561
|
+
props: { text: 'hello' },
|
|
562
|
+
position: { x: 0, y: 0 },
|
|
563
|
+
})
|
|
564
|
+
await flushSetImmediate()
|
|
565
|
+
const sizeAfter = fs.statSync(filePath).size
|
|
566
|
+
expect(sizeAfter).toBeLessThan(2 * 1024 * 1024)
|
|
567
|
+
// File should still contain both canvas_created + widget_added events.
|
|
568
|
+
const events = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
|
|
569
|
+
expect(events.length).toBe(2)
|
|
570
|
+
expect(events[0].event).toBe('canvas_created')
|
|
571
|
+
expect(events[1].event).toBe('widget_added')
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('compacts the file once it exceeds the runaway threshold', async () => {
|
|
575
|
+
const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
|
|
576
|
+
|
|
577
|
+
// Pre-bloat the file past 2 MB by appending raw garbage events. Each
|
|
578
|
+
// event has a unique id so the materializer treats it as valid.
|
|
579
|
+
const padding = 'x'.repeat(4000)
|
|
580
|
+
const bloatLines = []
|
|
581
|
+
for (let i = 0; i < 600; i++) {
|
|
582
|
+
bloatLines.push(JSON.stringify({
|
|
583
|
+
id: `evt_bloat_${i}`,
|
|
584
|
+
event: 'widget_added',
|
|
585
|
+
timestamp: '2026-01-01',
|
|
586
|
+
widget: { id: `bloat-${i}`, type: 'sticky-note', position: { x: 0, y: 0 }, props: { text: padding } },
|
|
587
|
+
}))
|
|
588
|
+
}
|
|
589
|
+
fs.appendFileSync(filePath, bloatLines.join('\n') + '\n')
|
|
590
|
+
const sizeBefore = fs.statSync(filePath).size
|
|
591
|
+
expect(sizeBefore).toBeGreaterThan(2 * 1024 * 1024)
|
|
592
|
+
|
|
593
|
+
// Trigger appendEvent — this should detect the runaway size and kick
|
|
594
|
+
// off a compaction.
|
|
595
|
+
await invoke('/widget', 'POST', {
|
|
596
|
+
name: 'big-canvas',
|
|
597
|
+
type: 'sticky-note',
|
|
598
|
+
props: { text: 'trigger' },
|
|
599
|
+
position: { x: 0, y: 0 },
|
|
600
|
+
})
|
|
601
|
+
// Compaction runs on setImmediate; flush it.
|
|
602
|
+
await flushSetImmediate()
|
|
603
|
+
await flushSetImmediate()
|
|
604
|
+
|
|
605
|
+
const sizeAfter = fs.statSync(filePath).size
|
|
606
|
+
expect(sizeAfter).toBeLessThan(sizeBefore)
|
|
607
|
+
|
|
608
|
+
// After compaction, file should be a single canvas_created event
|
|
609
|
+
// containing all the materialized widgets.
|
|
610
|
+
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n')
|
|
611
|
+
expect(lines.length).toBe(1)
|
|
612
|
+
const baseline = JSON.parse(lines[0])
|
|
613
|
+
expect(baseline.event).toBe('canvas_created')
|
|
614
|
+
// 600 bloat widgets + 1 from the triggering append = 601.
|
|
615
|
+
expect(baseline.widgets.length).toBe(601)
|
|
616
|
+
// Sanity: state still reads cleanly via the normal /read endpoint.
|
|
617
|
+
await invoke('/read?name=big-canvas', 'GET', {})
|
|
618
|
+
expect(lastResponse.status).toBe(200)
|
|
619
|
+
expect(lastResponse.body.widgets.length).toBe(601)
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe('image routes — drafts/ subdirectory privacy layer', () => {
|
|
624
|
+
// Minimal 1x1 PNG (base64) for upload tests.
|
|
625
|
+
const PNG_DATA_URL =
|
|
626
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAeImBZsAAAAASUVORK5CYII='
|
|
627
|
+
|
|
628
|
+
it('routes a draft-canvas upload into images/drafts/ and returns the drafts/ prefix', async () => {
|
|
629
|
+
const { invoke, root, lastResponse } = setup()
|
|
630
|
+
|
|
631
|
+
await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'drafts/storyboarding' })
|
|
632
|
+
|
|
633
|
+
expect(lastResponse.status).toBe(201)
|
|
634
|
+
expect(lastResponse.body.success).toBe(true)
|
|
635
|
+
expect(lastResponse.body.filename).toMatch(/^drafts\//)
|
|
636
|
+
const filename = lastResponse.body.filename
|
|
637
|
+
const basename = filename.slice('drafts/'.length)
|
|
638
|
+
const draftFile = path.join(root, 'assets', 'canvas', 'images', 'drafts', basename)
|
|
639
|
+
const publicFile = path.join(root, 'assets', 'canvas', 'images', basename)
|
|
640
|
+
expect(fs.existsSync(draftFile)).toBe(true)
|
|
641
|
+
expect(fs.existsSync(publicFile)).toBe(false)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('routes a non-draft-canvas upload into images/ as before', async () => {
|
|
645
|
+
const { invoke, root, lastResponse } = setup()
|
|
646
|
+
|
|
647
|
+
await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'storyboarding' })
|
|
648
|
+
|
|
649
|
+
expect(lastResponse.status).toBe(201)
|
|
650
|
+
expect(lastResponse.body.filename).not.toMatch(/^drafts\//)
|
|
651
|
+
const filename = lastResponse.body.filename
|
|
652
|
+
const publicFile = path.join(root, 'assets', 'canvas', 'images', filename)
|
|
653
|
+
expect(fs.existsSync(publicFile)).toBe(true)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('treats a canvas with `drafts` anywhere in its segments as draft', async () => {
|
|
657
|
+
const { invoke, root, lastResponse } = setup()
|
|
658
|
+
|
|
659
|
+
await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'widget/drafts/v6' })
|
|
660
|
+
|
|
661
|
+
expect(lastResponse.status).toBe(201)
|
|
662
|
+
expect(lastResponse.body.filename).toMatch(/^drafts\//)
|
|
663
|
+
const draftFile = path.join(root, 'assets', 'canvas', 'images', 'drafts',
|
|
664
|
+
lastResponse.body.filename.slice('drafts/'.length))
|
|
665
|
+
expect(fs.existsSync(draftFile)).toBe(true)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('GET /images/drafts/<basename> serves a draft image', async () => {
|
|
669
|
+
const { invoke, root, lastResponse } = setup()
|
|
670
|
+
const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
|
|
671
|
+
fs.mkdirSync(draftsDir, { recursive: true })
|
|
672
|
+
const payload = Buffer.from([1, 2, 3])
|
|
673
|
+
fs.writeFileSync(path.join(draftsDir, 'hello.png'), payload)
|
|
674
|
+
|
|
675
|
+
await invoke('/images/drafts/hello.png', 'GET', {})
|
|
676
|
+
expect(lastResponse.status).toBe(200)
|
|
677
|
+
expect(lastResponse.bytes).toEqual(payload)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('GET /images/<basename> does NOT fall back to drafts/', async () => {
|
|
681
|
+
const { invoke, root, lastResponse } = setup()
|
|
682
|
+
const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
|
|
683
|
+
fs.mkdirSync(draftsDir, { recursive: true })
|
|
684
|
+
fs.writeFileSync(path.join(draftsDir, 'leak.png'), Buffer.from([1, 2, 3]))
|
|
685
|
+
|
|
686
|
+
await invoke('/images/leak.png', 'GET', {})
|
|
687
|
+
expect(lastResponse.status).toBe(404)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('GET rejects deeper paths than one optional drafts/ segment', async () => {
|
|
691
|
+
const { invoke, lastResponse } = setup()
|
|
692
|
+
|
|
693
|
+
await invoke('/images/drafts/sub/foo.png', 'GET', {})
|
|
694
|
+
expect(lastResponse.status).toBe(400)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('GET rejects path traversal even with drafts prefix', async () => {
|
|
698
|
+
const { invoke, lastResponse } = setup()
|
|
699
|
+
|
|
700
|
+
await invoke('/images/drafts/../etc.png', 'GET', {})
|
|
701
|
+
expect(lastResponse.status).toBe(400)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
it('toggle-private preserves drafts/ prefix in returned filename', async () => {
|
|
705
|
+
const { invoke, root, lastResponse } = setup()
|
|
706
|
+
const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
|
|
707
|
+
fs.mkdirSync(draftsDir, { recursive: true })
|
|
708
|
+
fs.writeFileSync(path.join(draftsDir, 'foo.png'), Buffer.from([1, 2, 3]))
|
|
709
|
+
|
|
710
|
+
await invoke('/image/toggle-private', 'POST', { filename: 'drafts/foo.png' })
|
|
711
|
+
|
|
712
|
+
expect(lastResponse.status).toBe(200)
|
|
713
|
+
expect(lastResponse.body).toEqual({ success: true, filename: 'drafts/~foo.png', private: true })
|
|
714
|
+
expect(fs.existsSync(path.join(draftsDir, '~foo.png'))).toBe(true)
|
|
715
|
+
expect(fs.existsSync(path.join(draftsDir, 'foo.png'))).toBe(false)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('toggle-private still works on bare basenames in images/', async () => {
|
|
719
|
+
const { invoke, root, lastResponse } = setup()
|
|
720
|
+
const imagesDir = path.join(root, 'assets', 'canvas', 'images')
|
|
721
|
+
fs.mkdirSync(imagesDir, { recursive: true })
|
|
722
|
+
fs.writeFileSync(path.join(imagesDir, 'bar.png'), Buffer.from([1, 2, 3]))
|
|
723
|
+
|
|
724
|
+
await invoke('/image/toggle-private', 'POST', { filename: 'bar.png' })
|
|
725
|
+
|
|
726
|
+
expect(lastResponse.status).toBe(200)
|
|
727
|
+
expect(lastResponse.body.filename).toBe('~bar.png')
|
|
728
|
+
expect(fs.existsSync(path.join(imagesDir, '~bar.png'))).toBe(true)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('duplicate preserves drafts/ prefix', async () => {
|
|
732
|
+
const { invoke, root, lastResponse } = setup()
|
|
733
|
+
const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
|
|
734
|
+
fs.mkdirSync(draftsDir, { recursive: true })
|
|
735
|
+
fs.writeFileSync(path.join(draftsDir, 'storyboarding--2026-01-01--12-00-00.png'), Buffer.from([1, 2, 3]))
|
|
736
|
+
|
|
737
|
+
await invoke('/image/duplicate', 'POST', { filename: 'drafts/storyboarding--2026-01-01--12-00-00.png' })
|
|
738
|
+
|
|
739
|
+
expect(lastResponse.status).toBe(201)
|
|
740
|
+
expect(lastResponse.body.filename).toMatch(/^drafts\/storyboarding--/)
|
|
741
|
+
})
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
describe('GET /folders and POST /create with folderKind', () => {
|
|
745
|
+
let root, canvasDir, invoke, lastResponse
|
|
746
|
+
|
|
747
|
+
beforeEach(() => {
|
|
748
|
+
({ invoke, root, canvasDir, lastResponse } = setup())
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
afterEach(() => {
|
|
752
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
it('GET /folders returns tagged entries distinguishing workspace from pages', async () => {
|
|
756
|
+
// Workspace folder (`.folder/` suffix) containing one canvas
|
|
757
|
+
fs.mkdirSync(path.join(canvasDir, 'marketing.folder'), { recursive: true })
|
|
758
|
+
writeCanvas(path.join(canvasDir, 'marketing.folder'), 'landing')
|
|
759
|
+
// Plain dir (multi-page canvas group) containing two canvases
|
|
760
|
+
fs.mkdirSync(path.join(canvasDir, 'tour'), { recursive: true })
|
|
761
|
+
writeCanvas(path.join(canvasDir, 'tour'), 'overview')
|
|
762
|
+
writeCanvas(path.join(canvasDir, 'tour'), 'detail')
|
|
763
|
+
|
|
764
|
+
await invoke('/folders', 'GET')
|
|
765
|
+
|
|
766
|
+
expect(lastResponse.status).toBe(200)
|
|
767
|
+
// Legacy flat list still present for back-compat
|
|
768
|
+
expect(lastResponse.body.folders).toContain('marketing')
|
|
769
|
+
expect(lastResponse.body.folders).toContain('tour')
|
|
770
|
+
// New tagged entries carry the kind
|
|
771
|
+
const entries = lastResponse.body.entries
|
|
772
|
+
expect(entries).toEqual(expect.arrayContaining([
|
|
773
|
+
{ name: 'marketing', kind: 'workspace' },
|
|
774
|
+
{ name: 'tour', kind: 'pages' },
|
|
775
|
+
]))
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
it('POST /create with folderKind:"workspace" creates a new .folder/ dir', async () => {
|
|
779
|
+
await invoke('/create', 'POST', {
|
|
780
|
+
name: 'landing',
|
|
781
|
+
folder: 'marketing',
|
|
782
|
+
folderKind: 'workspace',
|
|
783
|
+
title: 'Landing',
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
expect(lastResponse.status).toBe(201)
|
|
787
|
+
expect(fs.existsSync(path.join(canvasDir, 'marketing.folder', 'landing.canvas.jsonl'))).toBe(true)
|
|
788
|
+
// Plain dir must NOT have been created
|
|
789
|
+
expect(fs.existsSync(path.join(canvasDir, 'marketing'))).toBe(false)
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
it('POST /create with folderKind:"pages" (or omitted) creates a plain dir', async () => {
|
|
793
|
+
await invoke('/create', 'POST', {
|
|
794
|
+
name: 'overview',
|
|
795
|
+
folder: 'tour',
|
|
796
|
+
folderKind: 'pages',
|
|
797
|
+
title: 'Overview',
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
expect(lastResponse.status).toBe(201)
|
|
801
|
+
expect(fs.existsSync(path.join(canvasDir, 'tour', 'overview.canvas.jsonl'))).toBe(true)
|
|
802
|
+
// .folder/ variant must NOT have been created
|
|
803
|
+
expect(fs.existsSync(path.join(canvasDir, 'tour.folder'))).toBe(false)
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
it('POST /create defaults to plain dir when folderKind is omitted (backward compat)', async () => {
|
|
807
|
+
await invoke('/create', 'POST', {
|
|
808
|
+
name: 'overview',
|
|
809
|
+
folder: 'tour',
|
|
810
|
+
title: 'Overview',
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
expect(lastResponse.status).toBe(201)
|
|
814
|
+
expect(fs.existsSync(path.join(canvasDir, 'tour', 'overview.canvas.jsonl'))).toBe(true)
|
|
815
|
+
expect(fs.existsSync(path.join(canvasDir, 'tour.folder'))).toBe(false)
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
it('POST /create reuses existing .folder/ regardless of folderKind', async () => {
|
|
819
|
+
fs.mkdirSync(path.join(canvasDir, 'marketing.folder'), { recursive: true })
|
|
820
|
+
|
|
821
|
+
// Even with folderKind:"pages", the existing .folder/ wins (filesystem
|
|
822
|
+
// truth beats a hint).
|
|
823
|
+
await invoke('/create', 'POST', {
|
|
824
|
+
name: 'landing',
|
|
825
|
+
folder: 'marketing',
|
|
826
|
+
folderKind: 'pages',
|
|
827
|
+
title: 'Landing',
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
expect(lastResponse.status).toBe(201)
|
|
831
|
+
expect(fs.existsSync(path.join(canvasDir, 'marketing.folder', 'landing.canvas.jsonl'))).toBe(true)
|
|
832
|
+
expect(fs.existsSync(path.join(canvasDir, 'marketing'))).toBe(false)
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
it('POST /create reuses existing plain dir regardless of folderKind', async () => {
|
|
836
|
+
fs.mkdirSync(path.join(canvasDir, 'tour'), { recursive: true })
|
|
837
|
+
|
|
838
|
+
await invoke('/create', 'POST', {
|
|
839
|
+
name: 'overview',
|
|
840
|
+
folder: 'tour',
|
|
841
|
+
folderKind: 'workspace',
|
|
842
|
+
title: 'Overview',
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
expect(lastResponse.status).toBe(201)
|
|
846
|
+
expect(fs.existsSync(path.join(canvasDir, 'tour', 'overview.canvas.jsonl'))).toBe(true)
|
|
847
|
+
expect(fs.existsSync(path.join(canvasDir, 'tour.folder'))).toBe(false)
|
|
848
|
+
})
|
|
849
|
+
})
|