openpencil/tests/engine/app/document/io/deep-link.test.ts
Marc Went 802091b051
feat: export components as Storybook stories (#751)
* feat(cli): export components as Storybook stories

Add `openpencil export -f storybook`, which writes one CSF3 `.stories.ts`
file per component set or component. Each variant becomes a story and the
variant properties become select controls, so the story renders the matching
variant; an unknown combination throws instead of showing another variant.

Stories embed the existing inline-style HTML projection, so consumers need no
OpenPencil runtime. `--framework react|vue|html` only changes the render
wrapper and the Meta/StoryObj import. When the document sits under the current
directory, stories carry an `openpencil://` design link for
@storybook/addon-designs.

Refs #727

* fix(pen): size auto-width text from its content on import

Text without a width in an auto-layout parent was imported 10000px wide, a placeholder the app's text measurer replaces. Headless layout keeps stored sizes, so CLI HTML and Storybook exports stretched hugging frames to over 10000px. Import the width as 0 so the importer's existing text-length estimate applies, and headless layout estimates the rest.

* feat(app): follow layer links to other pages

openpencil:// and web ?node= links only searched the current page, so a Storybook story linking to a component on another page reported it missing. When the current page has no match, load the other pages without showing them and switch to the first that carries the name.

* feat(cli): add design images and watch mode to Storybook export

Each story now links to its own variant when the layer name is unique, and carries a 2x PNG of the variant for @storybook/addon-designs, imported so Vite bundles it. --watch re-exports on every save. Re-exports replace the stories a previous export of the same document generated, including those of deleted components, and refuse to overwrite hand-written stories or another document's.

Refs #727

* fix(cli): reference Storybook design images without ambient PNG types

Import design images with new URL(..., import.meta.url) instead of an import declaration, so consumers need no vite/client types to typecheck the stories. Document that exports should run from the same directory.

* fix(app): search other pages for a link without cancelling page switches

The cross-page layer search prepared each page with preparePage, which advances the page-switch generation, so a page switch the user had in progress could be dropped, and every searched page paid for fonts and layout. Add loadPageNodes, which populates a page's layers through the same worker path without touching the switch generation, and report a failed search as an error instead of a missing layer.

* fix(pen): never import width-less text zero wide

Text without a width now imports at width 0 and relies on the importer's text-length estimate, which skipped single-glyph text. Estimate zero-width text of any length.

* fix(cli): harden Storybook export ownership, titles, and links

- A --page export replaces only its own stories, and names files as a full export does, so it cannot delete or overwrite other pages' stories.
- Same-named components on a page get distinct titles, so Storybook story ids do not collide.
- Read the generated header through CRLF line endings, and refuse a source containing a line break, which would end the header comment and start code.
- Link a story only to a layer name no other layer carries.
- Document the --page default for Storybook export.

Refs #727

* fix(app): let a page switch overtake a link's layer search

A link search that loads other pages could resume after the user started switching pages and move them to the matching page. Expose pageSwitchCount, which advances whenever a page switch starts, and abandon the search when it changes. An overtaken search reports neither a match nor a missing layer.

* fix(pen): estimate only omitted text widths

Estimate a width-less text node's width when it is imported, instead of estimating every zero-width text node afterwards, so an explicit width of 0 is kept.

* fix(cli): track Storybook story ownership by document path and page

- Identify the document by its path relative to the output directory rather than a basename or cwd-relative path, so same-named documents do not share stories and the export no longer depends on the working directory.
- Record the page in each story's header; a --page export replaces all of that page's stories and asks for a full export when renumbered file names land on another page's.
- Check every target, including design images, before removing anything, and refuse to overwrite files this export does not own.
- Quote the header fields as JSON with U+2028/U+2029 escaped, so any path stays inside the comment, instead of refusing line breaks.
- Deduplicate titles by Storybook id, which ignores case and punctuation.

Refs #727

* fix(app): focus a searched page only after its switch committed

A page switch the user starts while the link search's own switch is pending can keep that switch from committing. Check that the search's switch was the only one and landed on its page before focusing; otherwise report the search as superseded.

* fix(pen): keep empty text without a width at zero

* fix(cli): remove only the design images a Storybook export generated

Replacing a story removed its whole .design folder, including files someone else put there. Read the images each owned story references, remove just those, and remove a .design folder only once it is empty.

Refs #727

* test(app): cover a page switch still pending during a link search

The previous test committed the overtaking switch, so the page check alone caught it. Advance the switch count without committing, so the test fails without the count check.

* fix(cli): stage Storybook exports and refuse linked design folders

- Write every file to a staging folder inside the output before removing the previous export, then move them into place, so a failed write no longer leaves the export half replaced.
- Refuse a .design path that is not a real folder, such as a symbolic link, before removing or writing images through it, so an export cannot reach outside the output directory.

Refs #727

* refactor(dom-css): print Storybook stories from a parsed template

Story modules were assembled from string fragments, so quoting and
layout were an implicit contract: the CLI found design images with a
regex that only matched double-quoted `new URL("…")` paths.

A story module is now one TypeScript template, parsed once with acorn
and its TypeScript plugin. Data is filled into `$placeholder` nodes and
the module is printed with esrap, which owns quoting and escaping. The
CLI reads referenced design images back through `storyImagePaths()`
instead of matching text. Tests import generated modules and assert
values rather than formatting.

* refactor(storybook): track generated files in a manifest

The export recovered which files it owned by parsing its own output: a
header regex over JSON-quoted strings, line-separator escaping, CRLF
handling, an AST walk for design images, and a path regex in the CLI.

A `.openpencil-stories.json` manifest now records the document and page
behind each generated file. The CLI validates it with Valibot, including
that every listed path stays inside the output folder, and the story
header is a plain note. Story ids use a copy of Storybook's `sanitize`,
tested against the installed Storybook; the previous rule treated `A§B`
and `A-B` as the same story. Export names use es-toolkit's `pascalCase`.

The CLI export command moves into `commands/export/`, dom-css splits
grouping and naming out of the Storybook exporter, and the CLI takes the
framework list from dom-css.

* fix(pen): keep explicit narrow text widths

A post-import pass widened every multi-character text narrower than two
font sizes, including widths the `.pen` file set on purpose, such as
`width: 0`. Omitted widths are now estimated when the text node is
created, so the pass only overrode explicit widths and is removed.

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-30 03:16:47 +04:00

233 lines
7.7 KiB
TypeScript

import { describe, expect, mock, test } from 'bun:test'
import { openDeepLink, resolveDeepLinkFile } from '@/app/document/io/deep-link'
/**
* Stands in for the `path_matches_suffix` Tauri command. The real comparator asks the
* platform (ASCII-case-insensitive on macOS and Windows, exact on Linux); these tests
* cover the resolver's own contract, and the case rule is tested in `deep_link.rs`.
*/
const matchesSuffix = async (candidate: string, relative: string): Promise<boolean> => {
const a = candidate.replaceAll('\\', '/')
const r = relative.replaceAll('\\', '/')
return a === r || a.endsWith(`/${r}`)
}
const caseInsensitiveMatches = async (candidate: string, relative: string): Promise<boolean> =>
matchesSuffix(candidate.toLowerCase(), relative.toLowerCase())
function io(overrides: Partial<Parameters<typeof openDeepLink>[2]> = {}) {
return {
choosePaths: mock(async (): Promise<string[]> => []),
openPath: mock(async (): Promise<void> => undefined),
activateTab: mock(async (): Promise<boolean> => true),
matchesSuffix,
...overrides
}
}
describe('resolveDeepLinkFile', () => {
test('matches an open tab whose path ends with the relative file', async () => {
expect(
await resolveDeepLinkFile(
'web/design/hikyo.pen',
['/r/hikyo/web/design/hikyo.pen'],
matchesSuffix
)
).toBe('/r/hikyo/web/design/hikyo.pen')
})
test('returns null when nothing matches', async () => {
expect(
await resolveDeepLinkFile('web/design/hikyo.pen', ['/other/x.pen'], matchesSuffix)
).toBeNull()
})
test('does not match a partial segment', async () => {
expect(
await resolveDeepLinkFile('design/hikyo.pen', ['/r/redesign/hikyo.pen'], matchesSuffix)
).toBeNull()
})
test('accepts backslashes on either side', async () => {
expect(
await resolveDeepLinkFile(
'web\\design\\hikyo.pen',
['C:\\r\\web\\design\\hikyo.pen'],
matchesSuffix
)
).toBe('C:\\r\\web\\design\\hikyo.pen')
})
test('takes the comparator verdict, so a case-insensitive filesystem still matches', async () => {
const paths = ['/r/hikyo/Web/Design/Hikyo.pen']
expect(await resolveDeepLinkFile('web/design/hikyo.pen', paths, matchesSuffix)).toBeNull()
expect(await resolveDeepLinkFile('web/design/hikyo.pen', paths, caseInsensitiveMatches)).toBe(
'/r/hikyo/Web/Design/Hikyo.pen'
)
})
test('returns the first matching open path', async () => {
expect(
await resolveDeepLinkFile('hikyo.pen', ['/a/hikyo.pen', '/b/hikyo.pen'], matchesSuffix)
).toBe('/a/hikyo.pen')
})
})
describe('openDeepLink', () => {
test('activates the tab of an already open file without re-reading it from disk', async () => {
const selectByName = mock(() => true)
const deps = io()
const notices: string[] = []
await openDeepLink(
{ path: 'web/design/hikyo.pen', node: 'Button/Large/Default' },
{
openPaths: () => ['/r/hikyo/web/design/hikyo.pen'],
selectByName,
notify: (message) => notices.push(message)
},
deps
)
expect(deps.choosePaths).not.toHaveBeenCalled()
// The resolved absolute path, never the link's own string, and never a disk read:
// a file that moved since its tab opened must still focus that tab.
expect(deps.activateTab).toHaveBeenCalledTimes(1)
expect(deps.activateTab).toHaveBeenCalledWith('/r/hikyo/web/design/hikyo.pen')
expect(deps.openPath).not.toHaveBeenCalled()
expect(selectByName).toHaveBeenCalledWith('Button/Large/Default')
expect(notices).toEqual([])
})
test('opens the path when its tab closed between the snapshot and the activate', async () => {
const deps = io({ activateTab: mock(async () => false) })
const notices: string[] = []
await openDeepLink(
{ path: 'web/design/hikyo.pen' },
{
openPaths: () => ['/r/hikyo/web/design/hikyo.pen'],
selectByName: () => true,
notify: (message) => notices.push(message)
},
deps
)
expect(deps.openPath).toHaveBeenCalledWith('/r/hikyo/web/design/hikyo.pen')
expect(deps.choosePaths).not.toHaveBeenCalled()
expect(notices).toEqual([])
})
test('opens the picked file and selects the node when no tab matches', async () => {
const selectByName = mock(() => true)
const deps = io({ choosePaths: mock(async () => ['/picked/web/design/hikyo.pen']) })
const notices: string[] = []
await openDeepLink(
{ path: 'web/design/hikyo.pen', node: 'Button/Large/Default' },
{
openPaths: () => [],
selectByName,
notify: (message) => notices.push(message)
},
deps
)
expect(deps.choosePaths).toHaveBeenCalledTimes(1)
// The picker branch reads the file: nothing is open yet, so there is no tab to focus.
expect(deps.activateTab).not.toHaveBeenCalled()
expect(deps.openPath).toHaveBeenCalledTimes(1)
expect(deps.openPath).toHaveBeenCalledWith('/picked/web/design/hikyo.pen')
expect(deps.openPath).not.toHaveBeenCalledWith('web/design/hikyo.pen')
expect(selectByName).toHaveBeenCalledWith('Button/Large/Default')
expect(notices).toHaveLength(1)
})
test('notifies when the node is missing', async () => {
const deps = io()
const notices: string[] = []
await openDeepLink(
{ path: 'web/design/hikyo.pen', node: 'Nope' },
{
openPaths: () => ['/r/hikyo/web/design/hikyo.pen'],
selectByName: () => false,
notify: (message) => notices.push(message)
},
deps
)
expect(deps.activateTab).toHaveBeenCalledWith('/r/hikyo/web/design/hikyo.pen')
expect(notices).toHaveLength(1)
expect(notices[0]).toContain('Nope')
})
test('reports a failed layer search as an error, not a missing layer', async () => {
const notices: string[] = []
await openDeepLink(
{ path: 'web/design/hikyo.pen', node: 'Button' },
{
openPaths: () => ['/r/hikyo/web/design/hikyo.pen'],
selectByName: async () => {
throw new Error('page failed to load')
},
notify: (message) => notices.push(message)
},
io()
)
expect(notices).toHaveLength(1)
expect(notices[0]).toContain('page failed to load')
})
test('reports a dismissed picker as nothing chosen, not a wrong file', async () => {
const deps = io({ choosePaths: mock(async () => []) })
const notices: string[] = []
await openDeepLink(
{ path: 'web/design/hikyo.pen', node: 'Button' },
{
openPaths: () => [],
selectByName: () => true,
notify: (message) => notices.push(message)
},
deps
)
expect(deps.choosePaths).toHaveBeenCalledTimes(1)
expect(deps.openPath).not.toHaveBeenCalled()
expect(deps.activateTab).not.toHaveBeenCalled()
// The locate prompt, then a dismissal notice that does not name an unchosen file.
expect(notices).toHaveLength(2)
expect(notices[1]).toBe('Link cancelled: no file was chosen.')
})
test('cancels the link when the picked file is not the requested one', async () => {
const deps = io({
choosePaths: mock(async () => ['/elsewhere/other.pen']),
openPath: mock(async (path: string): Promise<void> => {
throw new Error(`unexpected open of ${path}`)
})
})
const notices: string[] = []
await openDeepLink(
{ path: 'web/design/hikyo.pen', node: 'Button' },
{
openPaths: () => [],
selectByName: () => true,
notify: (message) => notices.push(message)
},
deps
)
expect(deps.choosePaths).toHaveBeenCalledTimes(1)
expect(deps.openPath).not.toHaveBeenCalled()
expect(deps.activateTab).not.toHaveBeenCalled()
expect(notices).toHaveLength(2)
expect(notices[1]).toContain('web/design/hikyo.pen')
})
})