Files
max-sixty__worktrunk/docs/tests/built-site.test.mjs
T
Maximilian Roos 89326f14af Publish shell integration, add a footer, dedupe global options (#4000)
Guides and site presentation: one home per topic, a shell-integration
page, a footer, and three rendering fixes.

**Global options were emitted once per command reference**

clap repeats the same ~20-line `Global Options:` block in every
reference it renders, so a page assembled from subdocs stacked 11 copies
on `/config/` and 13 on `/step/`. That padded the pages and gave site
search that many near-identical hits — "squash" returned both
`#command-reference` and `#command-reference-2`. `take_global_options`
cuts each reference at the heading as it is built, keeping only the
first; one `kept` flag threads through the subdoc expansion and the page
streams out rather than accumulating. Terminal `--help` renders through
clap directly and is unchanged.

The config page also carried colliding anchors — two "Hooks" (`#hooks`,
`#hooks-1`), two "Aliases", and seven "Examples" (`#examples` …
`#examples-6`) — now qualified at their source in `src/cli/config.rs`:
User/Project hooks, User/Project aliases, and
Approval/Alias/State/Cache/Log/Variable examples.

`/step/` still has its own set (eight "Examples", two "Options", two
"Arguments", plus "Staging" and "Dry run" pairs). Qualifying those moves
existing `/step/#examples-N` anchors, so it wants a pass of its own with
the inbound links audited; the deduplication above already removes 13
Global Options blocks from that page.

**Shell integration has a page**

Shell-integration debugging was skill-only: five named warning messages,
a PowerShell checklist, and the wrapper mechanism, with no site page —
while the FAQ's answer to "`wt switch` didn't cd" was to install the
Claude Code plugin. It is now `/shell-integration/`, offered first, with
the plugin as the second route. The `llms.txt` listing serves every page
as `/<slug>.md` from a hand-created symlink, so a new page was a 404 the
listing still advertised; the symlink is added and the sync now fails
when a listed page has none.

**Presentation**

- A site footer carries the version (read from `Cargo.toml` at build
time), releases, changelog and license. No page named any of them, and
`/code-signing/` was reachable only from inside a collapsed block on the
homepage. Starlight's `Footer` is wrapped rather than replaced.
- `wt list --full` renders 1157px inside an 800px content column, so 40%
of it sat behind a horizontal scrollbar with the pane beside the column
empty. A terminal frame now takes the whole pane where there is slack,
measured with a query container rather than recomputed from Starlight's
layout formula.
- The `wt-command-reference` frames offered a copy button for 3,877
characters of generated help text; they now expose no copy control. A
console block listing several commands is as often a menu of
alternatives as a recipe, and nothing in the markup tells them apart, so
every command line in such a block carries its own copy control
alongside the block's.
- The four command demos and the two hand-written figures get captions;
the 2.33 MB homepage GIF below the fold loads lazily.

**Sidebar order is pinned**

`site-navigation.mjs` told readers a
`test_sidebar_matches_frontmatter_order` would fail when the authored
sidebar and the pages' `sidebar.order` disagreed. No such test existed,
and the disagreement it describes is exactly what the survey found:
`remove` listed before `merge`, Agent integration ahead of
lower-numbered pages. The test is written, so the sidebar and the
`llms.txt` ordering derived from the frontmatter can't drift apart
again.

<details>
<summary>Guide corrections</summary>

- Tips & patterns was 26 flat H2 recipes in no order, all 26 in the
sidebar. They group under five H2s — setup and layout, aliases and
hooks, per-worktree services, working with agents, status/commits/logs —
with each recipe demoted to H3. Anchors are level-independent, so
existing `/tips-patterns/#…` fragments still resolve.
- `-x 'opencode run'` has been broken since 0.75.0 made `-x` a literal
program: it is `-x opencode -- run '<task>'`.
- The branch-summary preview moved from tab 5 to 6 when the unified-diff
tab landed; the recipe names the `summary` tab instead of a number.
- The Caddy recipe claimed `feature-auth` hashes to port 16460 — that is
`fix-auth`'s port. It is 18283.
- `_` in `wt list` is same-commit *and clean*; the
same-commit-with-changes glyph is `–`, which is not safe to delete.
- `wt step prune` removes branches with no worktree too, and the min-age
guard ages a worktree by its creation time and a bare branch by its
oldest reflog entry.
- `wt step eval -v` prints fifteen variables; the example showed two
under a lead calling them "the available template variables".
- A filter applied to `{{ vars.<key> }}` acts on the placeholder the
preview substitutes, so `{{ vars.port | default('8080') }}` previews as
`{{ vars.port }}`, filter gone.
- The `.git/wt/cache/` table was missing `picker-preview`, and `wt
config state clear` prompts unless `--yes`.
- `skills/worktrunk/reference/README.md` was a symlink to the repo
README that `SKILL.md` never referenced, and the plugin mirror
dereferenced it into a 262-line copy carrying the star-history token,
share links, and a logo path resolving nowhere. Nothing generated it, so
deleting the symlink is the whole fix.
- One home per topic: agent handoffs stay in tips-patterns, activity
markers in `claude-code.md`, alias-template deferral in `extending.md`,
and the `codename` filter's two `worktree-path` recipes give way to the
config page that owns path templates. The FAQ's "Running tests" and "How
can I contribute?" duplicated the README's Contributing block down to
the share URLs.
- The FAQ linked `/worktrunk/#install`, the `noindex` compatibility
route; the plugin hook shim's Windows Terminal hint pointed there too.
Both use `/#install`, where the new sidebar Install entry goes.
- Example names settle on `myproject` / `feature-auth`; "sibling to main
repo" becomes "sibling to the main worktree", and `wt remove`'s "target
worktree" becomes "the worktree being removed" per the project's own
terminology rule.

</details>

UX survey items: `#36`, `#37`, `#38`, `#39`, `#45`, `#47`, `#48`, `#49`,
`#50`, `#51`, `#52`, `#61`, `#94`, `#95`, `#96`, `#97`, `#99`, `#100`.

Reviewable files: the hand-written pages under `docs/src/content/docs/`
(notably the new `shell-integration.md`, `tips-patterns.md`, `faq.md`),
`docs/src/components/Footer.astro`,
`docs/src/plugins/worktrunk-terminal.mjs`,
`docs/src/site-navigation.mjs`, `docs/tests/*.mjs`, `src/help.rs`,
`plugins/worktrunk/hooks/wt.sh`. Generated mirrors and snapshots are
regenerated.

> _This was written by Claude Code on behalf of max-sixty_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01XAUYWFN9d9oh6jyoQiouHb

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 12:44:47 -07:00

634 lines
26 KiB
JavaScript

import assert from 'node:assert/strict';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
keepCurrentTocLinkVisible,
syncTocCurrentToHash,
} from '../src/components/toc-scroll.mjs';
const docsRoot = fileURLToPath(new URL('..', import.meta.url));
const dist = path.join(docsRoot, 'dist');
const publicRoutes = [
'/',
'/claude-code/',
'/code-signing/',
'/config/',
'/extending/',
'/faq/',
'/hook/',
'/list/',
'/llm-commits/',
'/merge/',
'/remove/',
'/step/',
'/switch/',
'/tips-patterns/',
];
async function htmlFiles(directory = dist) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(entries.map((entry) => {
const file = path.join(directory, entry.name);
return entry.isDirectory() ? htmlFiles(file) : entry.name.endsWith('.html') ? [file] : [];
}));
return nested.flat();
}
async function allFiles(directory = dist) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(entries.map((entry) => {
const file = path.join(directory, entry.name);
return entry.isDirectory() ? allFiles(file) : [file];
}));
return nested.flat();
}
function routeFile(pathname) {
if (pathname === '/') return path.join(dist, 'index.html');
if (pathname === '/404/') return path.join(dist, '404.html');
if (pathname.endsWith('/')) return path.join(dist, pathname, 'index.html');
if (pathname.endsWith('.html')) return path.join(dist, pathname);
}
const renderedPages = [
'/404/',
...publicRoutes,
'/worktrunk/',
].map(routeFile);
function fileRoute(file) {
const relative = path.relative(dist, file);
if (relative === 'index.html') return '/';
if (relative === '404.html') return '/404/';
return `/${path.dirname(relative)}/`;
}
function tableSummaries(html) {
const text = (value) => value
.replace(/<[^>]*>/g, '')
.replaceAll('&#x26;', '&')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
return [...html.matchAll(/<table\b([^>]*)>([\s\S]*?)<\/table>/g)].map((match) => {
const body = match[2].match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? '';
const rows = [...body.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/g)];
return {
attributes: match[1],
headers: [...match[2].matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/g)]
.map((header) => text(header[1])),
rowLabels: rows.map((row) => (
[...row[1].matchAll(/<span\b([^>]*)>([\s\S]*?)<\/span>/g)]
.filter((label) => (
/\baria-hidden="true"/.test(label[1])
&& /\bclass="[^"]*\bwt-responsive-record-label\b[^"]*"/.test(label[1])
))
.map((label) => text(label[2]))
)),
};
});
}
function renderedText(html) {
return html
.replace(/<[^>]*>/g, '')
.replace(/&#x([\da-f]+);/giu, (_, value) => String.fromCodePoint(Number.parseInt(value, 16)))
.replace(/&#(\d+);/gu, (_, value) => String.fromCodePoint(Number(value)))
.replaceAll('&quot;', '"')
.replaceAll('&gt;', '>')
.replaceAll('&lt;', '<')
.replaceAll('&amp;', '&');
}
test('build preserves the public route contract', async () => {
const files = new Set(await htmlFiles());
const buildFiles = new Set(await allFiles());
const expected = [
path.join(dist, '404.html'),
...publicRoutes.map(routeFile),
routeFile('/worktrunk/'),
];
for (const file of expected) assert.ok(files.has(file), `missing ${file}`);
const sitemap = await readFile(path.join(dist, 'sitemap-0.xml'), 'utf8');
for (const route of publicRoutes) {
assert.match(sitemap, new RegExp(`<loc>https://worktrunk\\.dev${route}</loc>`));
}
assert.doesNotMatch(sitemap, /worktrunk\.dev\/(?:404|worktrunk)\//);
const robots = await readFile(path.join(dist, 'robots.txt'), 'utf8');
for (const match of robots.matchAll(/^Sitemap:\s+(\S+)$/gm)) {
const url = new URL(match[1]);
assert.equal(url.origin, 'https://worktrunk.dev');
assert.ok(buildFiles.has(path.join(dist, url.pathname)), `robots.txt references missing ${url.pathname}`);
}
const compatibilityPage = await readFile(routeFile('/worktrunk/'), 'utf8');
assert.match(compatibilityPage, /<link rel="canonical" href="https:\/\/worktrunk\.dev\/"/);
assert.match(compatibilityPage, /<meta name="robots" content="noindex"/);
assert.match(compatibilityPage, /<meta property="og:url" content="https:\/\/worktrunk\.dev\/"/);
assert.doesNotMatch(compatibilityPage, /<meta property="og:url" content="https:\/\/worktrunk\.dev\/worktrunk\/"/);
const homepage = await readFile(routeFile('/'), 'utf8');
assert.match(homepage, /<title>Worktrunk — Git worktree management for parallel AI agent workflows<\/title>/);
assert.doesNotMatch(homepage, /data-contract="home-hero"/);
assert.doesNotMatch(homepage, /data-has-hero/);
assert.doesNotMatch(homepage, />\s*Learn More\s*</);
assert.match(
homepage,
/<a\b[^>]*data-contract="github-stars"[^>]*href="https:\/\/github\.com\/max-sixty\/worktrunk"[^>]*>[\s\S]*?<img\b[^>]*src="https:\/\/img\.shields\.io\/github\/stars\/max-sixty\/worktrunk\?style=social"[^>]*>[\s\S]*?<\/a>/,
);
assert.doesNotMatch(homepage, />\s*GitHub stars\s*</);
assert.match(
homepage,
/<div\b[^>]*class="wt-home"[^>]*>[\s\S]*?<p>Worktrunk is a CLI for Git worktree management, designed for <strong>parallel AI agent\s+workflows<\/strong>\.<\/p>/,
);
assert.match(homepage, /<button\b[^>]*aria-label="Menu"[^>]*aria-controls="starlight__sidebar"/);
assert.match(homepage, /<div id="starlight__sidebar" class="sidebar-pane\b/);
assert.match(homepage, /<a\b[^>]*href="\/switch\/"[^>]*><span\b[^>]*>wt switch<\/span><\/a>/);
assert.match(homepage, /<a\b[^>]*href="\/faq\/"[^>]*><span\b[^>]*>FAQ<\/span><\/a>/);
assert.doesNotMatch(homepage, /data-has-toc/);
assert.doesNotMatch(homepage, /<nav aria-labelledby="starlight__on-this-page">/);
assert.doesNotMatch(homepage, /<starlight-theme-select>/);
assert.match(homepage, /<div\b[^>]*class="wt-home"[^>]*data-contract="home-content-rail"/);
assert.match(homepage, /<h1 id="_top"[^>]*><span id="worktrunk"[^>]*>Worktrunk<\/span><\/h1>/);
assert.match(
homepage,
/<source srcset="\/assets\/docs\/dark\/wt-core-mobile\.gif"[^>]*data-demo-theme="dark"[^>]*data-demo-media="\(max-width: 42rem\)"/,
);
assert.match(
homepage,
/<source srcset="\/assets\/docs\/light\/wt-core-mobile\.gif"[^>]*data-demo-theme="light"[^>]*data-demo-media="\(max-width: 42rem\)"/,
);
assert.match(homepage, /<img src="\/assets\/docs\/light\/wt-core\.gif"/);
assert.equal(homepage.match(/<h1\b/g)?.length, 1);
assert.match(
homepage,
/<pre[^>]*><code><span class="wt-shell-command">git<\/span> <span class="wt-shell-argument">worktree<\/span> <span class="wt-shell-argument">add<\/span> <span class="wt-shell-option">-b<\/span>/,
);
const comparison = homepage.match(/<table class="cmd-compare">([\s\S]*?)<\/table>/)?.[1];
assert.ok(comparison, 'homepage is missing the command comparison table');
assert.equal(comparison.match(/<td data-label="Worktrunk">/g)?.length, 4);
assert.equal(comparison.match(/<td data-label="Plain git">/g)?.length, 4);
assert.equal(comparison.match(/class="wt-shell-command"/g)?.length, 12);
assert.equal(comparison.match(/class="wt-shell-option"/g)?.length, 4);
assert.equal(comparison.match(/class="wt-shell-argument"/g)?.length, 21);
assert.match(homepage, /class="wt-terminal-green"/);
assert.match(homepage, /class="wt-terminal-red"/);
const switchPage = await readFile(routeFile('/switch/'), 'utf8');
assert.match(switchPage, /<nav aria-labelledby="starlight__on-this-page">/);
assert.match(
switchPage,
/<a\b[^>]*data-contract="github-stars"[^>]*href="https:\/\/github\.com\/max-sixty\/worktrunk"[^>]*>[\s\S]*?<img\b[^>]*src="https:\/\/img\.shields\.io\/github\/stars\/max-sixty\/worktrunk\?style=social"[^>]*>[\s\S]*?<\/a>/,
);
assert.doesNotMatch(switchPage, /<starlight-theme-select>/);
const listPage = await readFile(routeFile('/list/'), 'utf8');
assert.match(listPage, /href="https:\/\/github\.com\/max-sixty\/worktrunk\/edit\/main\/docs\/src\/content\/docs\/list\.md"/);
assert.doesNotMatch(listPage, /docs\/src\/content\/docs\/src\/content\/docs/);
assert.match(listPage, /<span class="wt-terminal-green">\+54<\/span>/);
assert.match(listPage, /<span class="wt-terminal-red">-5<\/span>/);
assert.match(listPage, /<span class="wt-terminal-blue wt-terminal-dim">#412<\/span>/);
assert.match(
listPage,
/<div class="ec-line wt-command"><div class="code"><span[^>]*>wt<\/span><span[^>]*> <\/span><span[^>]*>list<\/span>/,
);
assert.match(listPage, /<figure class="frame wt-command-reference not-content">/);
assert.match(listPage, /<span class="wt-help-heading">Usage:<\/span>/);
assert.match(listPage, /<span class="wt-help-option">--format<\/span>/);
assert.match(listPage, /<span class="wt-help-value">\[OPTIONS\]<\/span>/);
assert.match(
listPage,
/<span class="wt-help-command">wt list<\/span> <span class="wt-help-value">&#x3C;COMMAND><\/span>/,
);
const llmCommitsPage = await readFile(routeFile('/llm-commits/'), 'utf8');
assert.match(llmCommitsPage, /<span class="wt-terminal-cyan">◎<\/span>/);
assert.match(
llmCommitsPage,
/<span class="wt-terminal-bold">feat\(validation\): add input validation utilities<\/span>/,
);
const configPage = await readFile(routeFile('/config/'), 'utf8');
assert.match(
configPage,
/<figure class="frame has-title not-content"><figcaption class="header"><span class="title">~\/.config\/worktrunk\/config\.toml<\/span><\/figcaption>/,
);
const configToc = configPage.match(
/<nav aria-labelledby="starlight__on-this-page">([\s\S]*?)<\/nav>/,
)?.[1];
assert.ok(configToc, 'config page is missing its desktop table of contents');
assert.equal(
[...configToc.matchAll(/<a href="#[^"]+"/g)].length,
33,
'config table of contents should expose overview and structural section headings only',
);
for (const [id, title] of [
['user-configuration', 'User Configuration'],
['project-configuration', 'Project Configuration'],
['shell-integration', 'Shell Integration'],
['other', 'Other'],
['subcommands', 'Subcommands'],
]) {
assert.match(
configToc,
new RegExp(`<a href="#${id}"[^>]*>[\\s\\S]*?<span[^>]*>${title}</span></a>`),
);
}
assert.doesNotMatch(configToc, /href="#claude-code"/);
assert.doesNotMatch(configToc, /href="#command-reference-1"/);
const structuralSections = new Map([
['/hook/', ['hook-types', 'security', 'configuration', 'running-hooks-manually', 'recipes']],
['/list/', ['subcommands']],
['/step/', ['subcommands']],
]);
for (const [route, ids] of structuralSections) {
const page = await readFile(routeFile(route), 'utf8');
const toc = page.match(/<nav aria-labelledby="starlight__on-this-page">([\s\S]*?)<\/nav>/)?.[1];
assert.ok(toc, `${route} is missing its desktop table of contents`);
for (const id of ids) assert.match(toc, new RegExp(`<a href="#${id}"`));
}
});
test('built pages omit low-value footer navigation', async () => {
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
assert.doesNotMatch(
html,
/class="[^"]*\bpagination-links\b/,
`${page} renders previous/next pagination`,
);
assert.doesNotMatch(
html,
/data-contract="project-links"/,
`${page} renders duplicate project links`,
);
}
});
test('nested command references expose unique search-only fragment titles', async () => {
const stepPage = await readFile(routeFile('/step/'), 'utf8');
assert.match(stepPage, /<span class="wt-help-value">&#x3C;COMMAND>\.\.\.<\/span>/);
assert.match(stepPage, /<h2 id="command-reference">Command reference<\/h2>/);
const references = [...stepPage.matchAll(
/<h3 id="command-reference-\d+"><span class="wt-pagefind-fragment-title" hidden aria-hidden="true">(wt step [^<]+) — <\/span>Command reference<\/h3>/g,
)].map((match) => match[1]);
assert.deepEqual(references, [
'wt step commit',
'wt step squash',
'wt step rebase',
'wt step push',
'wt step diff',
'wt step copy-ignored',
'wt step eval',
'wt step for-each',
'wt step promote',
'wt step prune',
'wt step relocate',
'wt step tether',
]);
const listPage = await readFile(routeFile('/list/'), 'utf8');
assert.match(
listPage,
/<h3 id="command-reference-1"><span class="wt-pagefind-fragment-title" hidden aria-hidden="true">wt list statusline — <\/span>Command reference<\/h3>/,
);
});
test('every command reference receives semantic syntax roles', async () => {
let headingCount = 0;
let frameCount = 0;
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
headingCount += [...html.matchAll(/>Command reference<\/h[23]>/g)].length;
const frames = [...html.matchAll(
/<figure class="frame wt-command-reference not-content">([\s\S]*?)<\/figure>/g,
)];
frameCount += frames.length;
for (const [, frame] of frames) {
const renderedCode = frame.match(/<code>([\s\S]*?)<\/code>/)?.[1] ?? '';
assert.match(frame, /class="wt-help-command"/, `${page} leaves the command unstyled`);
assert.match(frame, /class="wt-help-heading"/, `${page} leaves help headings unstyled`);
assert.match(frame, /class="wt-help-(?:option|value)"/, `${page} leaves help syntax unstyled`);
assert.doesNotMatch(
frame,
/<div class="code">- [^<]/,
`${page} leaves a possible-value row unstyled`,
);
const experimentalCount = [...renderedCode.matchAll(/\[experimental\]/g)].length;
const styledExperimentalCount = [
...renderedCode.matchAll(/class="wt-help-meta">\[experimental\]<\/span>/g),
].length;
assert.equal(
styledExperimentalCount,
experimentalCount,
`${page} leaves an experimental annotation unstyled`,
);
}
}
assert.ok(headingCount > 0, 'expected rendered command references');
assert.equal(frameCount, headingCount, 'some command references remain plain code blocks');
});
test('built pages only reference emitted local assets', async () => {
const files = new Set(await allFiles());
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
const assertAsset = (reference) => {
const url = new URL(reference.replaceAll('&amp;', '&'), `https://worktrunk.dev${fileRoute(page)}`);
if (url.origin !== 'https://worktrunk.dev' || url.pathname.endsWith('/')) return;
const target = path.join(dist, decodeURIComponent(url.pathname));
assert.ok(files.has(target), `${page} references missing asset ${url.pathname}`);
};
for (const match of html.matchAll(/\s(href|src|srcset)="([^"]+)"/g)) {
const references = match[1] === 'srcset'
? match[2].split(',').map((candidate) => candidate.trim().split(/\s+/, 1)[0])
: [match[2]];
for (const reference of references) assertAsset(reference);
}
for (const match of html.matchAll(/<meta (?:property="og:image"|name="twitter:image") content="([^"]+)"/g)) {
assertAsset(match[1]);
}
}
});
test('output-only console blocks do not expose copy controls', async () => {
let outputOnlyBlocks = 0;
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
for (const match of html.matchAll(/<figure class="frame is-terminal[^"]*">([\s\S]*?)<\/figure>/g)) {
const frame = match[1];
if (!/class="ec-line wt-output"/.test(frame)) continue;
if (/class="ec-line wt-(?:command|copyable)"/.test(frame)) continue;
outputOnlyBlocks += 1;
assert.doesNotMatch(frame, /class="copy"/, `${page} exposes a copy control for captured output`);
}
}
assert.equal(outputOnlyBlocks, 1, 'expected the FAQ approval transcript to exercise this case');
});
test('command-bearing console blocks emit command-only copy payloads', async () => {
let commandBearingBlocks = 0;
let perLineBlocks = 0;
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
for (const match of html.matchAll(/<figure class="frame is-terminal[^"]*">([\s\S]*?)<\/figure>/g)) {
const frame = match[1];
const lines = [...frame.matchAll(
/<div class="ec-line wt-(command|copyable|output)"><div class="code">([\s\S]*?)<\/div>/g,
)];
const expected = lines
.filter((line) => line[1] !== 'output')
.map((line) => renderedText(line[2]).replace(/\n$/u, ''));
if (expected.length === 0) continue;
commandBearingBlocks += 1;
// The block control carries the bare `copy` class; per-line controls add
// `wt-line-copy`, so this anchors on the block's own payload.
const encodedPayload = frame.match(
/<div class="copy">[\s\S]*?<button\b[^>]*\bdata-code="([^"]*)"/,
)?.[1];
assert.notEqual(encodedPayload, undefined, `${page} is missing a terminal copy payload`);
assert.equal(renderedText(encodedPayload), expected.join('\u007f'), `${page} copies captured output`);
// A block listing several commands is as often a menu of alternatives as
// a recipe, so each command line offers its own payload alongside the
// block's.
const commands = lines
.filter((line) => line[1] === 'command')
.map((line) => renderedText(line[2]).replace(/\n$/u, ''));
const perLine = [...frame.matchAll(
/<div class="copy wt-line-copy">[\s\S]*?<button\b[^>]*\bdata-code="([^"]*)"/g,
)].map((line) => renderedText(line[1]));
if (commands.length > 1) {
perLineBlocks += 1;
assert.deepEqual(perLine, commands, `${page} per-line copy payloads do not match its commands`);
} else {
assert.deepEqual(perLine, [], `${page} adds per-line copy to a single-command block`);
}
}
}
assert.ok(commandBearingBlocks > 0, 'expected command-bearing console blocks');
assert.ok(perLineBlocks > 0, 'expected blocks listing several commands');
});
test('generated command references expose no copy control', async () => {
let references = 0;
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
for (const match of html.matchAll(
/<figure class="frame[^"]*\bwt-command-reference\b[^"]*">([\s\S]*?)<\/figure>/g,
)) {
references += 1;
assert.doesNotMatch(
match[1],
/class="copy/,
`${page} offers to copy a page of generated help text`,
);
}
}
assert.ok(references > 0, 'expected generated command references');
});
test('titleless code frames do not render decorative headers', async () => {
for (const page of renderedPages) {
const html = await readFile(page, 'utf8');
assert.doesNotMatch(
html,
/<figcaption class="header"><span class="title"><\/span>/,
`${page} renders chrome for an untitled code block`,
);
}
});
test('long desktop TOCs keep the current link inside their own scrollport', () => {
const scrollport = {
clientHeight: 1_000,
scrollHeight: 1_961,
scrollTop: 0,
getBoundingClientRect: () => ({ top: 0, bottom: 1_000 }),
};
const visibleToc = {
closest: () => scrollport,
getClientRects: () => [{}],
};
const current = {
closest: () => visibleToc,
getBoundingClientRect: () => ({ top: 1_386.84375, bottom: 1_411.09375 }),
};
assert.equal(keepCurrentTocLinkVisible(current), true);
assert.equal(scrollport.scrollTop, 459.09375);
current.getBoundingClientRect = () => ({ top: 500, bottom: 525 });
assert.equal(keepCurrentTocLinkVisible(current), false);
assert.equal(scrollport.scrollTop, 459.09375);
current.getBoundingClientRect = () => ({ top: 20, bottom: 45 });
assert.equal(keepCurrentTocLinkVisible(current), true);
assert.equal(scrollport.scrollTop, 431.09375);
visibleToc.getClientRects = () => [];
current.getBoundingClientRect = () => ({ top: 1_386.84375, bottom: 1_411.09375 });
assert.equal(keepCurrentTocLinkVisible(current), false);
assert.equal(scrollport.scrollTop, 431.09375);
visibleToc.getClientRects = () => [{}];
scrollport.scrollHeight = scrollport.clientHeight;
assert.equal(keepCurrentTocLinkVisible(current), false);
assert.equal(scrollport.scrollTop, 431.09375);
});
test('hash navigation selects the requested section in every rendered TOC', () => {
const link = (hash, current = false) => ({
hash,
current,
getAttribute(name) {
return name === 'aria-current' && this.current ? 'true' : null;
},
setAttribute(name, value) {
if (name === 'aria-current') this.current = value === 'true';
},
removeAttribute(name) {
if (name === 'aria-current') this.current = false;
},
textContent: hash.slice(1).replaceAll('-', ' '),
});
const root = (mobile = false) => {
const overview = link('#_top', true);
const requested = link('#reporting-a-problem');
const display = mobile ? { textContent: '' } : null;
return {
display,
links: [overview, requested],
querySelector(selector) {
return selector === '.display-current' ? display : null;
},
querySelectorAll(selector) {
if (selector === 'a') return this.links;
if (selector === 'a[aria-current="true"]') {
return this.links.filter((candidate) => candidate.current);
}
return [];
},
};
};
const desktop = root();
const mobile = root(true);
const doc = {
querySelectorAll: () => [desktop, mobile],
};
assert.equal(syncTocCurrentToHash(doc, '#reporting-a-problem'), true);
for (const toc of [desktop, mobile]) {
assert.equal(toc.links[0].current, false);
assert.equal(toc.links[1].current, true);
}
assert.equal(mobile.display.textContent, 'reporting a problem');
assert.equal(syncTocCurrentToHash(doc, '#missing'), false);
});
test('every structural content heading appears in the page outline', async () => {
for (const route of publicRoutes.filter((candidate) => candidate !== '/')) {
const page = await readFile(routeFile(route), 'utf8');
const headings = [...page.matchAll(/<h[12]\b[^>]*\bid="([^"]+)"/g)]
.map((match) => match[1])
.filter((id) => id !== 'starlight__on-this-page');
const toc = page.match(/<nav aria-labelledby="starlight__on-this-page">([\s\S]*?)<\/nav>/)?.[1];
assert.ok(toc, `${route} is missing its desktop table of contents`);
const links = [...toc.matchAll(/<a href="#([^"]+)"/g)].map((match) => match[1]);
assert.deepEqual(links, headings, `${route} outline does not match its structural headings`);
}
});
test('short wide tables become labeled records without capturing dense tables', async () => {
const pages = new Map(await Promise.all(publicRoutes.map(async (route) => (
[route, await readFile(routeFile(route), 'utf8')]
))));
const tables = new Map([...pages].map(([route, html]) => [route, tableSummaries(html)]));
const findTable = (route, headers) => {
const table = tables.get(route).find((candidate) => (
candidate.headers.join('|') === headers.join('|')
));
assert.ok(table, `${route} is missing table ${headers.join(' | ')}`);
return table;
};
const configFiles = findTable(
'/config/',
['File', 'Location', 'Contains', 'Committed & shared'],
);
assert.match(configFiles.attributes, /class="wt-responsive-records"/);
for (const pageTables of tables.values()) {
for (const table of pageTables) {
if (!/\bwt-responsive-records\b/.test(table.attributes)) continue;
assert.ok(table.headers.length >= 3);
assert.ok(table.rowLabels.length >= 1 && table.rowLabels.length <= 3);
assert.deepEqual(
table.rowLabels,
Array.from({ length: table.rowLabels.length }, () => table.headers),
);
}
}
assert.doesNotMatch(
findTable('/extending/', ['', 'Hooks', 'Aliases', 'Custom subcommands']).attributes,
/\bwt-responsive-records\b/,
);
assert.doesNotMatch(
findTable('/claude-code/', ['Capability', 'Claude Code', 'Codex', 'OpenCode', 'Pi', 'Gemini CLI']).attributes,
/\bwt-responsive-records\b/,
);
assert.doesNotMatch(
findTable('/hook/', ['Kind', 'Variable', 'Description']).attributes,
/\bwt-responsive-records\b/,
);
assert.doesNotMatch(
findTable('/list/', ['Column', 'Shows']).attributes,
/\bwt-responsive-records\b/,
);
assert.doesNotMatch(
findTable('/', ['Task', 'Worktrunk', 'Plain git']).attributes,
/\bwt-responsive-records\b/,
);
});
test('mobile menu control is hidden until its script is available', async () => {
const faqPage = await readFile(routeFile('/faq/'), 'utf8');
assert.match(faqPage, /<starlight-menu-button\b/);
assert.match(
faqPage,
/<style>\s*starlight-menu-button:not\(:defined\)\s*\{\s*display:\s*none;\s*\}\s*<\/style>/,
);
});
test('built pages have unique IDs and valid internal page links', async () => {
const pages = await htmlFiles();
const contents = new Map(await Promise.all(pages.map(async (file) => [file, await readFile(file, 'utf8')])));
for (const [source, html] of contents) {
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
assert.equal(new Set(ids).size, ids.length, `duplicate ID in ${source}`);
for (const match of html.matchAll(/\shref="([^"]+)"/g)) {
const href = match[1].replaceAll('&amp;', '&');
const url = new URL(href, `https://worktrunk.dev${fileRoute(source)}`);
if (url.origin !== 'https://worktrunk.dev') continue;
const targetFile = routeFile(decodeURIComponent(url.pathname));
if (!targetFile) continue;
const target = contents.get(targetFile);
assert.ok(target, `${source} links to missing page ${url.pathname}`);
if (!url.hash) continue;
const id = decodeURIComponent(url.hash.slice(1));
assert.match(target, new RegExp(`\\sid="${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`), `${source} links to missing ${url.hash}`);
}
}
});