diff --git a/docs/quickstart.md b/docs/quickstart.md index c1ff558b..fdef5291 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -23,7 +23,13 @@ To keep what it found in a file you can edit, run `npx -y onsystem init`: it wri ## Hold your agent to it -Pick one: +Give your agent this prompt, and it does the rest of the setup itself: the config and the baseline, onsystem in the agent, and the CI check. [setup.md](https://onsystem.vercel.app/setup.md) has the steps it follows. + +```text title="Prompt for your agent" +Read https://onsystem.vercel.app/setup.md and set up onsystem in this project. +``` + +Or add it yourself. Pick one: - **Claude Code**: install the [plugin](plugin.md). Its hook checks every UI file Claude writes and hands the errors back before Claude moves on; it also brings the MCP server and a skill that has Claude look components up first. diff --git a/site/app/page.tsx b/site/app/page.tsx index 27678eca..a77dc980 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -15,11 +15,13 @@ import { InlineCode, Section, SubSection, TextLink } from '@/components/section' import { Tabs } from '@/components/tabs'; import { count } from '@/lib/benchmark'; import { benchmark, corpus, demo, readme, rules, tools } from '@/lib/data'; +import { SETUP_PROMPT } from '@/lib/llms'; import { pageMetadata } from '@/lib/metadata'; import { findingCard, HOME_CARD, TSC_RULES } from '@/lib/og'; import { highlight } from '@/lib/render-code'; import { AUTHOR, + CODEX_COMMANDS, CURSOR_INSTALL, docsHref, NPM, @@ -105,9 +107,7 @@ function GetStarted() { /** What a visitor runs first: findings with fixes on their own app, nothing installed or written. */ const CHECK = 'npx -y onsystem check .'; -/** The commands from docs/clients.md and docs/required-check.md (test/first-screen.test.ts checks). */ -const CODEX = - 'codex plugin marketplace add dgesteves/onsystem\ncodex plugin add onsystem@dgesteves'; +/** The step from docs/required-check.md (test/first-screen.test.ts checks). */ const ACTION = '- uses: dgesteves/onsystem@v0'; const MARKETPLACE = 'https://github.com/marketplace/actions/onsystem-design-system-check'; @@ -148,13 +148,17 @@ function Hero() { .

- - Try it in the browser - - + {/* One row, so wide screens are no taller; it wraps on a phone. */} +
+ + Try it in the browser + + + +
@@ -230,6 +234,33 @@ function CheckCommand() { ); } +/** + * The prompt that has the visitor's agent set onsystem up from /setup.md. The label makes its + * text part of the copy button: a click on either copies the prompt, which the title shows. + * Touch screens show no title, so below sm the prompt itself is on the line under it. From sm + * up the control stays on one line, so the wide first screen is no taller. + */ +function AgentPrompt() { + return ( + + ); +} + /** * Three of the demo draft's findings as the agent gets them, with the line it wrote, and what * `tsc` reports of the whole draft. The social card draws the same card (lib/og.tsx). @@ -325,7 +356,7 @@ function InstallTabs() { label="Install for" // No-break spaces keep each label on one line; the row fits beside the card from 1024 px. labels={['Claude\u00a0Code', 'Cursor', 'VS\u00a0Code', 'Codex', 'Other\u00a0MCP', 'CI']} - copies={[PLUGIN_COMMANDS, undefined, undefined, CODEX, NPX, ACTION]} + copies={[PLUGIN_COMMANDS, undefined, undefined, CODEX_COMMANDS, NPX, ACTION]} > {[ @@ -365,7 +396,7 @@ function InstallTabs() { Other agents , )} - + , {note( diff --git a/site/app/setup.md/route.ts b/site/app/setup.md/route.ts new file mode 100644 index 00000000..90a3b440 --- /dev/null +++ b/site/app/setup.md/route.ts @@ -0,0 +1,8 @@ +import { setupMd } from '@/lib/llms'; + +// The steps an agent follows to set onsystem up, written once per build from the docs. +export const dynamic = 'force-static'; + +export function GET() { + return new Response(setupMd(), { headers: { 'content-type': 'text/markdown; charset=utf-8' } }); +} diff --git a/site/components/playground.tsx b/site/components/playground.tsx index 22f3eb67..7ada0704 100644 --- a/site/components/playground.tsx +++ b/site/components/playground.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { Line } from '@/components/code-line'; import { FindingMessage } from '@/components/finding-message'; -import { ArrowUpRightIcon, CheckIcon, ReplayIcon } from '@/components/icons'; +import { ArrowRightIcon, ArrowUpRightIcon, CheckIcon, ReplayIcon } from '@/components/icons'; import type { CheckError, CheckResponse } from '@/lib/check-service'; import { highlightLines } from '@/lib/highlight'; import type { Mark } from '@/lib/segments'; @@ -302,6 +302,12 @@ export function Playground({ presets, maxBytes }: { presets: Preset[]; maxBytes: : current.result.errorCount + current.result.warningCount === 0 ? 'No design-system problems' : `${plural(current.result.errorCount, 'error')} · ${plural(current.result.warningCount, 'warning')}`; + // Below lg the findings come after the editor, a long file further down: the status there is a + // link to them. + const found = + !empty && !problem && !pending && current + ? current.result.errorCount + current.result.warningCount + : 0; return (
@@ -344,10 +350,19 @@ export function Playground({ presets, maxBytes }: { presets: Preset[]; maxBytes: {preset && code === preset.code ? preset.file : 'Your code'} 0 ? 'text-error' : current.result.warningCount > 0 ? 'text-warn' : 'text-success'}`} + className={`shrink-0 text-[11px] ${found > 0 ? 'max-lg:hidden' : ''} ${empty || pending || !current || problem ? 'text-subtle' : current.result.errorCount > 0 ? 'text-error' : current.result.warningCount > 0 ? 'text-warn' : 'text-success'}`} > {status} + {found > 0 && ( + + {`Jump to ${plural(found, 'finding')}`} + + + )}
diff --git a/site/lib/llms.ts b/site/lib/llms.ts index 0f99f8ef..d2619463 100644 --- a/site/lib/llms.ts +++ b/site/lib/llms.ts @@ -6,6 +6,7 @@ import { faqMarkdown } from '@/lib/faq'; import { withoutMarkers } from '@/lib/markers'; import { playground } from '@/lib/playground'; import { + CODEX_COMMANDS, NPM, NPX, PLUGIN_COMMANDS, @@ -32,6 +33,105 @@ const pitch = const details = 'It reads React components, props, cva variants and tokens from source with the TypeScript compiler. In Claude Code, a hook checks each file right after it is written and hands the errors back with their fixes; other agents run check_ui over MCP; the CI check gates pull requests. Static analysis, run locally: no model calls, no API key, zero config. It works alongside @shadcn/lint.'; +/** + * What a visitor gives their agent to have it set onsystem up, as @shadcn/lint's quickstart gives + * one for its SETUP.md: the home page's first screen and step 2 of the quickstart offer it. + */ +export const SETUP_PROMPT = `Read ${SITE_URL}/setup.md and set up onsystem in this project.`; + +/** The code of the block titled `title` in a docs page, so /setup.md shows what the docs show. */ +function titled(markdown: string, title: string): { lang: string; code: string } { + const at = markdown.indexOf(` title="${title}"\n`); + const lang = /```(\w+)$/.exec(markdown.slice(0, at))?.[1]; + if (at === -1 || !lang) throw new Error(`No code block titled ${title} in the docs`); + const start = markdown.indexOf('\n', at) + 1; + return { lang, code: markdown.slice(start, markdown.indexOf('\n```', start)) }; +} + +/** A fenced code block, indented to sit in a list item. */ +function fence({ lang, code }: { lang: string; code: string }, indent = ''): string { + return [`\`\`\`${lang}`, ...code.split('\n'), '```'].map((line) => indent + line).join('\n'); +} + +/** + * /setup.md: the steps an agent follows to set onsystem up in the project it is working in. The + * configs and the workflow are the ones docs/rollout.md and docs/required-check.md give. + */ +export function setupMd(): string { + const config = (file: string) => fence(titled(docs.rollout, file), ' '); + const [marketplace, install] = PLUGIN_COMMANDS.split('\n'); + const [codexMarketplace, codexInstall] = CODEX_COMMANDS.split('\n'); + const node = readme.engines.node.replace('>=', ''); + return `# Set up onsystem + +These are the steps to set onsystem up in the user's project. onsystem keeps coding agents on the project's design system: it reads the components, their props and variants, and the tokens from source, gives the agent MCP tools to look them up and \`check_ui\` to check its own UI code, and checks pull requests in CI. Make only the changes below, and ask the user before anything else, such as fixing what it finds. + +The docs are at ${SITE_URL}/docs, and all of them in one file at ${SITE_URL}/llms-full.txt. + +## 1. Look at the project + +- onsystem checks React (\`.tsx\` and \`.jsx\`) and needs Node.js ${node} or later. If the project has no React UI, stop and tell the user. +- Find the package manager from the lockfile, and whether this is one app or a monorepo. If the dependencies are not installed, install them first: without them, the props of components that wrap a library (Radix, React Aria) are not read. +- Set it up in the folder that CI will check: the app's folder, or the root of a monorepo, where each app and design-system package is checked against its own design system. + +## 2. Write the config and record the baseline + +In that folder, run: + +\`\`\`sh +npx -y onsystem init --yes +\`\`\` + +It writes \`onsystem.config.json\` from the components and tokens it finds, checks the project once, and records the findings already in the code in \`onsystem.baseline.json\`, so that the check fails only on new ones. Keep what it prints for step 5. + +- If it says a config already exists, keep that config: do not pass \`--force\`. If there is no \`onsystem.baseline.json\` next to it, record the findings already in the code with \`npx -y onsystem check . --update-baseline\`. +- If it found no design system, run \`npx -y onsystem inspect --explain\` to see where it looked, ask the user where the components and tokens are, and run it again with them: \`npx -y onsystem init --yes --components "src/components/ui/**/*.tsx" --tokens src/app/globals.css\`. + +## 3. Add it to your agent + +Add onsystem to the agent you are running in, in files the repository keeps, so the whole team gets it. Merge into a file that is already there; do not replace it. + +- **Claude Code**: the plugin, with the hook that checks each file Claude writes, the MCP server and the skill. \`.claude/settings.json\`: + +${config('.claude/settings.json')} + + Claude Code loads it the next time it starts in this folder, once the user accepts the folder's trust prompt. Do not add onsystem to \`.mcp.json\` as well: both register the same tools. To install it for the user alone instead, they run \`${marketplace ?? ''}\`, then \`${install ?? ''}\`. +- **Cursor**: the MCP server. \`.cursor/mcp.json\`: + +${config('.cursor/mcp.json')} + +- **VS Code**: the MCP server, for Copilot agent mode. \`.vscode/mcp.json\`: + +${config('.vscode/mcp.json')} + +- **Codex CLI**: the plugin, with the MCP server and the skill: \`${codexMarketplace ?? ''}\`, then \`${codexInstall ?? ''}\`. +- **Any other agent**: the MCP server runs over stdio as \`${NPX} --root \`, and [Set up your agent](${SITE_URL}/docs/clients#other-clients) has the config for each client. If the project has an \`AGENTS.md\`, add this line to it: + +${config('AGENTS.md')} + +## 4. Check pull requests + +On GitHub, add \`.github/workflows/onsystem.yml\`. It runs the GitHub Action, which checks the lines each pull request changes, leaves out the findings in the baseline, and fails on errors: + +${fence(titled(docs.requiredCheck, '.github/workflows/onsystem.yml'))} + +Install the dependencies the way the project does: with pnpm, add \`pnpm/action-setup\` before \`actions/setup-node\` and run \`pnpm install --frozen-lockfile\` ([Roll out to a team](${SITE_URL}/docs/rollout#gate-pull-requests)). When the folder from step 1 is not the root, give the action that folder as \`working-directory\` ([GitHub Action](${SITE_URL}/docs/required-check)). On another CI, run \`${NPX} check . --require-design-system\` in that folder. + +## 5. Check it works, and report + +1. Run \`${NPX} check . --require-design-system\` in the folder from step 1, and look at its exit code: + - 0: the check passes. The findings already in the code are in the baseline. + - 1: it found errors that are not in the baseline, such as ones in code written since it was recorded. Do not fix them: list them for the user in the next step. + - 2: it found no design system or could not read the config: go back to step 2. +2. Tell the user: + - the files you added or changed, to review and commit: \`onsystem.config.json\`, \`onsystem.baseline.json\`, and the agent's and CI's files; + - what the baseline holds (what \`init\` printed by rule, or the count \`check . --update-baseline\` printed), and that \`${NPX} check . --ignore-baseline\` lists each finding with its fix; + - with code 1, the findings the check printed, each with its fix; + - what is left for them to do, such as restarting the agent so it loads the plugin or the MCP server. +3. Leave the findings as they are unless the user asks you to fix them. +`; +} + /** llms.txt (https://llmstxt.org): what this is, how to install it, and where everything is. */ export function llmsTxt(): string { const lines = [ @@ -43,6 +143,8 @@ export function llmsTxt(): string { '', `Version ${tools.version}, MIT. React (.tsx/.jsx), shadcn/ui or any design system, Tailwind v3 or v4, Node.js ${readme.engines.node.replace('>=', '')}+.`, '', + `To set onsystem up in a project, follow ${SITE_URL}/setup.md: the config and baseline (\`npx -y onsystem init --yes\`), the agent's plugin or MCP config, and the CI check. A person can give their agent this prompt: "${SETUP_PROMPT}"`, + '', 'Install:', '', `- Claude Code (hook, MCP server and skill): \`${PLUGIN_COMMANDS.split('\n').join('` then `')}\``, diff --git a/site/lib/site.ts b/site/lib/site.ts index 0d8afd6e..73c94ca1 100644 --- a/site/lib/site.ts +++ b/site/lib/site.ts @@ -29,6 +29,9 @@ export function compactNumber(value: number): string { export const NPX = 'npx -y onsystem'; export const PLUGIN_COMMANDS = readme.install.plugin; +/** The Codex CLI plugin, as docs/clients.md installs it (site/test/setup.test.ts checks). */ +export const CODEX_COMMANDS = + 'codex plugin marketplace add dgesteves/onsystem\ncodex plugin add onsystem@dgesteves'; export const CURSOR_INSTALL = readme.install.cursor; export const VSCODE_INSTALL = readme.install.vscode; diff --git a/site/test/first-screen.test.ts b/site/test/first-screen.test.ts index 2f8f5e51..fede735d 100644 --- a/site/test/first-screen.test.ts +++ b/site/test/first-screen.test.ts @@ -10,10 +10,11 @@ import Home, { metadata } from '@/app/page'; import { STEPS } from '@/components/loop-demo'; import { slug } from '@/components/markdown-blocks'; import { benchmark, corpus, demo, designSystem } from '@/lib/data'; +import { SETUP_PROMPT } from '@/lib/llms'; import { HOME_CARD, HomeCard } from '@/lib/og'; import type { Preset } from '@/lib/playground'; import { applyFixes } from '@/lib/playground-edits'; -import { DOCS_FILES } from '@/lib/site'; +import { CODEX_COMMANDS, DOCS_FILES } from '@/lib/site'; // The home page's first screen (ROADMAP-v2 section 3.8), the demo it leads into, and the social // card drawn from the same finding card. @@ -124,6 +125,32 @@ describe('the first screen', () => { expect(shown).not.toContain('Star on GitHub'); }); + it('offers the setup prompt for your agent beside Try it in the browser, before the card', () => { + const at = (marker: string) => firstScreen.indexOf(marker); + expect(at('data-agent-prompt')).toBeGreaterThan(at('Try it in the browser')); + expect(at('data-agent-prompt')).toBeLessThan(at('data-finding-card')); + // One row with Try it in the browser, which wraps on a phone: no taller first screen on wide + // screens, where the install tabs and the proof row sit under it. + const row = /
\s*]*href="\/playground"[\s\S]*?data-agent-prompt/.exec( + firstScreen, + ); + expect(row?.[1]).toMatch(/\bflex\b/); + expect(row?.[1]).toMatch(/\bflex-wrap\b/); + const prompt = /]*data-agent-prompt[\s\S]*?<\/label>/.exec(firstScreen)?.[0] ?? ''; + expect(text(prompt)).toContain('Or ask your agent to set it up'); + // The label's text and the button copy the same prompt; the button says which. + expect(decode(prompt)).toContain(`title="${SETUP_PROMPT}"`); + expect(decode(prompt)).toMatch(/]*aria-label="Copy the prompt for your agent: Read /); + // Touch screens never show the title: below sm the label shows the prompt it copies, so a + // phone visitor sees what they copy. From sm up it is hidden, and the row stays one line. + const shown = /]*data-agent-prompt-text[^>]*class="([^"]*)"[^>]*>([^<]*)<\/span>/.exec( + prompt, + ); + expect(decode(shown?.[2] ?? '')).toBe(`“${SETUP_PROMPT}”`); + expect(shown?.[1]).toMatch(/\bsm:hidden\b/); + expect(shown?.[1]).toMatch(/\bbasis-full\b/); + }); + it('shows three of the demo’s findings in a card, with the import and what tsc reports', () => { const card = /]*data-finding-card[\s\S]*?<\/figure>/.exec(firstScreen)?.[0] ?? ''; const shown = flat(card); @@ -151,10 +178,7 @@ describe('the first screen', () => { ); const shown = flat(firstScreen); // The commands are the docs' own. - for (const command of [ - 'codex plugin marketplace add dgesteves/onsystem', - 'codex plugin add onsystem@dgesteves', - ]) { + for (const command of CODEX_COMMANDS.split('\n')) { expect(shown).toContain(command); expect(read('docs/clients.md')).toContain(command); } diff --git a/site/test/playground.test.ts b/site/test/playground.test.ts index 9cdb37f3..334035ab 100644 --- a/site/test/playground.test.ts +++ b/site/test/playground.test.ts @@ -2,9 +2,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { DesignSystem, type DesignSystemModel, type ResolvedConfig } from 'onsystem'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import type { Preset } from '@/lib/playground'; +import { Playground } from '@/components/playground'; +import { PLAYGROUND_LIMITS, type Preset } from '@/lib/playground'; import { applyEdits, applyFixes, @@ -264,3 +267,38 @@ describe('reflow', () => { expect(reflow('', mixed)).toBe(mixed); }); }); + +describe('on a phone', () => { + // Below lg the findings come after the editor: on a 390 px screen the Agent draft's 1,355 px + // editor puts them 2.4 screens down, so the status in the editor's bar is a link to them. + const html = renderToStaticMarkup( + createElement(Playground, { presets, maxBytes: PLAYGROUND_LIMITS.maxBytes }), + ); + const bar = /
([\s\S]*?)<\/div>/.exec(html)?.[1] ?? ''; + + it('jumps from the editor’s bar to the findings', () => { + const link = /]*)>([\s\S]*?)<\/a>/.exec(bar); + expect(link?.[1] ?? '').toContain('href="#findings"'); + expect(link?.[2]).toMatch(/^Jump to 11 findings/); + expect(html).toMatch(/
]*id="findings"/); + }); + + it('keeps the counts on wide screens, where the findings sit beside the editor', () => { + const link = /]*)>/.exec(bar)?.[1] ?? ''; + expect(link).toMatch(/class="[^"]*\blg:hidden\b/); + const status = /8 errors · 3 warnings<\/span>/.exec(bar)?.[1] ?? ''; + expect(status).toMatch(/\bmax-lg:hidden\b/); + }); + + it('has no link for a draft with no findings', () => { + const clean = presets.filter((p) => p.result.diagnostics.length === 0); + expect(clean.length).toBeGreaterThan(0); + for (const p of clean) { + const page = renderToStaticMarkup( + createElement(Playground, { presets: [p], maxBytes: PLAYGROUND_LIMITS.maxBytes }), + ); + expect(page).not.toContain('Jump to'); + expect(page).toContain('No design-system problems'); + } + }); +}); diff --git a/site/test/setup.test.ts b/site/test/setup.test.ts new file mode 100644 index 00000000..7ffcb92c --- /dev/null +++ b/site/test/setup.test.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { dynamic, GET } from '@/app/setup.md/route'; +import { slug } from '@/components/markdown-blocks'; +import { docs } from '@/lib/data'; +import { DOCS_PAGES } from '@/lib/docs'; +import { llmsTxt, SETUP_PROMPT } from '@/lib/llms'; +import { CODEX_COMMANDS, PLUGIN_COMMANDS, SITE_URL } from '@/lib/site'; + +// /setup.md: the steps an agent follows to set onsystem up in a project, and the one prompt that +// points it there, as @shadcn/lint's quickstart does with its SETUP.md. +const root = path.join(import.meta.dirname, '../..'); +const read = (file: string) => fs.readFileSync(path.join(root, file), 'utf8'); + +const setup = async () => { + const response = GET(); + expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8'); + return response.text(); +}; + +/** The code of the fenced block titled `title` in a docs page's Markdown. */ +function titled(markdown: string, title: string): string { + const at = markdown.indexOf(` title="${title}"\n`); + expect(at, title).toBeGreaterThan(-1); + const start = markdown.indexOf('\n', at) + 1; + return markdown.slice(start, markdown.indexOf('\n```', start)); +} + +describe('/setup.md', () => { + it('is Markdown, written once per build', async () => { + expect(dynamic).toBe('force-static'); + expect(await setup()).toMatch(/^# Set up onsystem\n\n/); + }); + + it('takes an agent through init --yes, its own config, the CI check and a report', async () => { + const body = await setup(); + const steps = [...body.matchAll(/^## (.+)$/gm)].map((m) => m[1]); + expect(steps).toEqual([ + '1. Look at the project', + '2. Write the config and record the baseline', + '3. Add it to your agent', + '4. Check pull requests', + '5. Check it works, and report', + ]); + // The commands test/setup-steps.test.ts runs on the demo app. + for (const command of [ + 'init --yes', + 'inspect --explain', + 'check . --update-baseline', + 'check . --require-design-system', + 'check . --ignore-baseline', + ]) { + expect(body).toContain(`npx -y onsystem ${command}`); + } + // Every agent the home page's tabs install for, and any other. + for (const agent of ['Claude Code', 'Cursor', 'VS Code', 'Codex CLI', 'Any other agent']) { + expect(body).toContain(`- **${agent}**`); + } + // npx without -y asks before it installs, which an agent's terminal cannot answer. (The + // AGENTS.md line, from the docs, is for a project that has it installed.) + expect(body.replace(/^ *```[\s\S]*?^ *```/gm, '')).not.toMatch(/npx onsystem/); + expect(body).toContain('Tell the user'); + }); + + it('records a baseline beside a config that is already there, and says what the check’s codes mean', async () => { + const body = await setup(); + // The user may have run `init` by hand (quickstart step 1) and declined the baseline. + expect(body.slice(body.indexOf('## 2. '), body.indexOf('## 3. '))).toContain( + 'If it says a config already exists, keep that config: do not pass `--force`. If there is no `onsystem.baseline.json` next to it, record the findings already in the code with `npx -y onsystem check . --update-baseline`.', + ); + const check = body.slice(body.indexOf('## 5. ')); + for (const code of [ + '0: the check passes', + '1: it found errors that are not in the baseline', + '2: it found no design system', + ]) { + expect(check).toContain(` - ${code}`); + } + // Code 1: the agent reports the new findings and leaves them. + expect(check).toContain('Do not fix them: list them for the user in the next step.'); + expect(check).toContain('with code 1, the findings the check printed, each with its fix'); + }); + + it('gives the configs and the workflow the docs give, word for word', async () => { + const body = await setup(); + for (const file of ['.claude/settings.json', '.cursor/mcp.json', '.vscode/mcp.json']) { + expect(body, file).toContain( + `\`${file}\`:\n\n \`\`\`json\n ${titled(docs.rollout, file).replace(/\n/g, '\n ')}\n \`\`\``, + ); + } + expect(body).toContain(titled(docs.rollout, 'AGENTS.md')); + expect(body).toContain(titled(docs.requiredCheck, '.github/workflows/onsystem.yml')); + for (const command of PLUGIN_COMMANDS.split('\n')) expect(body).toContain(`\`${command}\``); + for (const command of CODEX_COMMANDS.split('\n')) { + expect(body).toContain(`\`${command}\``); + expect(read('docs/clients.md')).toContain(command); + } + }); + + it('links with absolute URLs, to pages the site has', async () => { + const body = await setup(); + const links = [...body.matchAll(/\]\(([^)]+)\)/g)].map((m) => m[1] ?? ''); + expect(links.length).toBeGreaterThanOrEqual(3); + const pages = new Set([...DOCS_PAGES.map((page) => page.href), '/llms-full.txt']); + for (const link of links) { + expect(link).toMatch(/^https:\/\//); + if (!link.startsWith(SITE_URL)) continue; + const [page = '', anchor] = link.slice(SITE_URL.length).split('#'); + expect(pages, link).toContain(page); + if (anchor) { + const file = DOCS_PAGES.find((p) => p.href === page)?.source ?? ''; + const ids = [...read(file).matchAll(/^#{2,6} (.+)$/gm)].map((m) => slug(m[1] ?? '')); + expect(ids, link).toContain(anchor); + } + } + }); +}); + +describe('the setup prompt', () => { + it('points the agent at /setup.md, as @shadcn/lint’s quickstart does at its SETUP.md', () => { + expect(SETUP_PROMPT).toBe( + 'Read https://onsystem.vercel.app/setup.md and set up onsystem in this project.', + ); + }); + + it('is in step 2 of the quickstart, as a block to copy', () => { + const quickstart = read('docs/quickstart.md'); + const step = quickstart.slice( + quickstart.indexOf('## Hold your agent to it'), + quickstart.indexOf('## Try it'), + ); + expect(step).toContain(`\`\`\`text title="Prompt for your agent"\n${SETUP_PROMPT}\n\`\`\``); + // Before the per-agent steps it stands in for. + expect(step.indexOf(SETUP_PROMPT)).toBeLessThan(step.indexOf('- **Claude Code**')); + }); + + it('is in llms.txt, with /setup.md', () => { + const txt = llmsTxt(); + expect(txt).toContain(`${SITE_URL}/setup.md`); + expect(txt).toContain(SETUP_PROMPT); + }); +}); diff --git a/test/setup-steps.test.ts b/test/setup-steps.test.ts new file mode 100644 index 00000000..3ca24426 --- /dev/null +++ b/test/setup-steps.test.ts @@ -0,0 +1,114 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { main, type Io } from '../src/cli.js'; +import { DEMO_ROOT, fixture } from './helpers.js'; + +// /setup.md (site/lib/llms.ts) has an agent run these commands, and says what each one does. They +// run here, on the demo app as a stranger's app is before anything of onsystem is in it, and as it +// is when the user ran `init` by hand and declined the baseline, with no terminal to answer +// questions in, as an agent runs them. site/test/setup.test.ts checks that /setup.md names them. + +/** A copy of the demo app, without its dependencies and with only the onsystem files in `keep`. */ +function demo(keep: string[] = []): string { + const root = fixture({}); + const leftOut = ['onsystem.config.json', 'onsystem.baseline.json'].filter( + (f) => !keep.includes(f), + ); + fs.cpSync(DEMO_ROOT, root, { + recursive: true, + filter: (src) => { + const rel = path.relative(DEMO_ROOT, src); + return rel !== 'node_modules' && !leftOut.includes(rel); + }, + }); + return root; +} + +async function run(args: string[], cwd: string) { + const out: string[] = []; + const io: Io = { + cwd, + color: false, + stdout: (t) => out.push(t), + stderr: (t) => out.push(t), + }; + const code = await main([...args, '--no-cache'], io); + return { code, text: out.join('\n') }; +} + +describe('the steps /setup.md gives an agent', () => { + it('write the config and the baseline, then pass the check, and keep a config that is there', async () => { + const root = demo(); + + // Step 2: the config, a check of the project by rule, and the baseline. + const init = await run(['init', '--yes'], root); + expect(init.code).toBe(0); + expect(init.text).toMatch( + /Dry run of `onsystem check \.`: \d+ errors? and \d+ warnings? .* By rule: /, + ); + expect(fs.existsSync(path.join(root, 'onsystem.config.json'))).toBe(true); + expect(fs.existsSync(path.join(root, 'onsystem.baseline.json'))).toBe(true); + + // Step 5: the check passes, since what is already there is in the baseline... + expect((await run(['check', '.', '--require-design-system'], root)).code).toBe(0); + // ...and --ignore-baseline lists each finding with its fix. + const all = await run(['check', '.', '--ignore-baseline'], root); + expect(all.code).toBe(1); + expect(all.text).toContain('Use