design-spine 0.1.0 → 0.2.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.
@@ -0,0 +1,196 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import { realpathSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ // Matches package.json engines.bun and the pin in action.yml; a test keeps them in step.
8
+ export const DOCKER_BUN_VERSION = '1.3.13'
9
+ // GitHub's ubuntu-latest is x64, so captures must be too, even on Apple silicon.
10
+ export const DOCKER_PLATFORM = 'linux/amd64'
11
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
12
+
13
+ export const imageTag = (playwrightVersion: string, bunVersion = DOCKER_BUN_VERSION): string =>
14
+ `design-spine-capture:${playwrightVersion}-bun${bunVersion}`
15
+
16
+ export const dockerfileText = (
17
+ playwrightVersion: string,
18
+ bunVersion = DOCKER_BUN_VERSION,
19
+ ): string =>
20
+ `FROM mcr.microsoft.com/playwright:v${playwrightVersion}-noble
21
+ COPY --from=oven/bun:${bunVersion} /usr/local/bin/bun /usr/local/bin/bun
22
+ `
23
+
24
+ /** Loopback hosts mean the host machine, which a container reaches as host.docker.internal. */
25
+ export function rewriteBaseForDocker(base: string): string {
26
+ const url = new URL(base)
27
+ if (!LOOPBACK.has(url.hostname)) return base
28
+ url.hostname = 'host.docker.internal'
29
+ const out = url.href
30
+ const bare = url.pathname === '/' && !url.search && !url.hash
31
+ return bare && !base.endsWith('/') ? out.slice(0, -1) : out
32
+ }
33
+
34
+ export function installedPlaywrightVersion(): string {
35
+ const pkg = createRequire(import.meta.url)('playwright/package.json') as { version: string }
36
+ return pkg.version
37
+ }
38
+
39
+ export interface CliLocation {
40
+ /** Extra `-v` mount for a CLI that lives outside the project root. */
41
+ mounts: string[]
42
+ /** CLI path as seen inside the container. */
43
+ path: string
44
+ }
45
+
46
+ export function locateCli(root: string, cliFile: string, packageDir: string): CliLocation {
47
+ const rel = relative(root, cliFile)
48
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel))
49
+ return { mounts: [], path: `/work/${rel.split(sep).join('/')}` }
50
+ return {
51
+ mounts: ['-v', `${packageDir}:/spine:ro`],
52
+ path: `/spine/${relative(packageDir, cliFile).split(sep).join('/')}`,
53
+ }
54
+ }
55
+
56
+ export interface InnerCapture {
57
+ manifest: string
58
+ base: string
59
+ routes?: string
60
+ concurrency: string
61
+ }
62
+
63
+ export function innerCaptureArgs(o: InnerCapture): string[] {
64
+ return [
65
+ 'capture',
66
+ '--manifest',
67
+ o.manifest,
68
+ '--base',
69
+ o.base,
70
+ ...(o.routes ? ['--routes', o.routes] : []),
71
+ '--concurrency',
72
+ o.concurrency,
73
+ ]
74
+ }
75
+
76
+ export interface RunArgvInput {
77
+ image: string
78
+ root: string
79
+ cli: CliLocation
80
+ recordedBase: string
81
+ inner: string[]
82
+ /** Set on Linux hosts so files written into the project stay owned by the caller. */
83
+ user?: { uid: number; gid: number }
84
+ /**
85
+ * Linux hosts share the host network, so an app bound only to localhost (the usual CI
86
+ * setup) is reachable without rewriting the URL. Docker Desktop and OrbStack use
87
+ * host.docker.internal instead.
88
+ */
89
+ hostNetwork?: boolean
90
+ }
91
+
92
+ export function dockerRunArgv(i: RunArgvInput): string[] {
93
+ return [
94
+ 'run',
95
+ '--rm',
96
+ '--platform',
97
+ DOCKER_PLATFORM,
98
+ '--init',
99
+ '--ipc=host',
100
+ ...(i.user ? ['--user', `${i.user.uid}:${i.user.gid}`, '-e', 'HOME=/tmp'] : []),
101
+ '-v',
102
+ `${i.root}:/work`,
103
+ ...i.cli.mounts,
104
+ '-w',
105
+ '/work',
106
+ ...(i.hostNetwork ? ['--network', 'host'] : ['--add-host=host.docker.internal:host-gateway']),
107
+ '-e',
108
+ 'PLAYWRIGHT_BROWSERS_PATH=/ms-playwright',
109
+ '-e',
110
+ 'SPINE_CWD=/work',
111
+ // The run keeps the URL the host used, so review and reports read the same as a host capture.
112
+ '-e',
113
+ `SPINE_RECORDED_BASE_URL=${i.recordedBase}`,
114
+ '-e',
115
+ `SPINE_HOST_ROOT=${i.root}`,
116
+ i.image,
117
+ 'bun',
118
+ i.cli.path,
119
+ ...i.inner,
120
+ ]
121
+ }
122
+
123
+ function docker(args: string[], opts: { input?: string; quiet?: boolean } = {}) {
124
+ return spawnSync('docker', args, {
125
+ input: opts.input,
126
+ stdio: [
127
+ opts.input === undefined ? 'ignore' : 'pipe',
128
+ opts.quiet ? 'ignore' : 'inherit',
129
+ 'inherit',
130
+ ],
131
+ })
132
+ }
133
+
134
+ export function assertDockerReady(): void {
135
+ const info = spawnSync('docker', ['info'], { stdio: 'ignore' })
136
+ if ((info.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT')
137
+ throw new Error('--docker needs the docker CLI, but `docker` is not on PATH.')
138
+ if (info.status !== 0)
139
+ throw new Error(
140
+ '--docker needs a running Docker daemon, but `docker info` failed. Start Docker or OrbStack.',
141
+ )
142
+ }
143
+
144
+ export function ensureImage(playwrightVersion: string, log: (s: string) => void): string {
145
+ const tag = imageTag(playwrightVersion)
146
+ if (docker(['image', 'inspect', tag], { quiet: true }).status === 0) return tag
147
+ log(`Building ${tag} (${DOCKER_PLATFORM}), first run only...`)
148
+ const built = docker(['build', '--platform', DOCKER_PLATFORM, '-t', tag, '-'], {
149
+ input: dockerfileText(playwrightVersion),
150
+ })
151
+ if (built.status !== 0) throw new Error(`docker build failed for ${tag}.`)
152
+ return tag
153
+ }
154
+
155
+ export interface DockerCapture extends InnerCapture {
156
+ root: string
157
+ /** Base URL as the host sees it, before the loopback rewrite. */
158
+ hostBase: string
159
+ log: (s: string) => void
160
+ }
161
+
162
+ /** Runs `spine capture` inside the pinned Playwright image and resolves to its exit code. */
163
+ export async function captureInDocker(o: DockerCapture): Promise<number> {
164
+ assertDockerReady()
165
+ const image = ensureImage(installedPlaywrightVersion(), o.log)
166
+ const cliFile = realpathSync(fileURLToPath(new URL('../cli.ts', import.meta.url)))
167
+ const root = realpathSync(o.root)
168
+ const cli = locateCli(root, cliFile, dirname(dirname(cliFile)))
169
+ const getuid = process.getuid?.()
170
+ const linux = process.platform === 'linux'
171
+ const argv = dockerRunArgv({
172
+ image,
173
+ root,
174
+ cli,
175
+ recordedBase: o.hostBase,
176
+ inner: innerCaptureArgs({ ...o, base: linux ? o.base : rewriteBaseForDocker(o.base) }),
177
+ hostNetwork: linux,
178
+ user:
179
+ linux && getuid !== undefined
180
+ ? { uid: getuid, gid: process.getgid?.() ?? getuid }
181
+ : undefined,
182
+ })
183
+ return await new Promise<number>((done, fail) => {
184
+ const child = spawn('docker', argv, { stdio: 'inherit' })
185
+ child.on('error', fail)
186
+ child.on('close', (code) => done(code ?? 1))
187
+ })
188
+ }
189
+
190
+ /** Manifest path relative to the project root, since the container sees the root as /work. */
191
+ export function manifestInRoot(root: string, manifest: string): string {
192
+ const rel = relative(root, resolve(root, manifest))
193
+ if (!rel || rel.startsWith('..') || isAbsolute(rel))
194
+ throw new Error('--docker needs the manifest inside the project root.')
195
+ return rel.split(sep).join('/')
196
+ }
package/src/lib/routes.ts CHANGED
@@ -170,6 +170,8 @@ export function draftManifest(
170
170
  baseUrl,
171
171
  viewports: [390, 1280],
172
172
  themes: { light: 'light', dark: 'dark', method: 'localStorage', key: 'theme' },
173
+ // Motion and unsettled pages are the main source of false diffs; start stable.
174
+ capture: { animations: 'disabled', stabilityAttempts: 3 },
173
175
  initScripts: [],
174
176
  routes,
175
177
  }
package/src/lib/server.ts CHANGED
@@ -3,6 +3,8 @@ import { readFileSync, realpathSync } from 'node:fs'
3
3
  import { basename, join, resolve, sep } from 'node:path'
4
4
  import { PNG } from 'pngjs'
5
5
  import UI_PATH from '../ui/index.html' with { type: 'file' }
6
+ import UI_CSS from '../ui/review.css' with { type: 'file' }
7
+ import UI_JS from '../ui/review.js' with { type: 'file' }
6
8
  import { buildComparisonAsync } from './comparison.ts'
7
9
  import { listRuns, selectRun } from './history.ts'
8
10
  import { buildReport } from './report.ts'
@@ -30,6 +32,18 @@ export function startReviewServer(initialRunDir: string, port: number) {
30
32
  headers: { 'content-type': 'text/html; charset=utf-8' },
31
33
  })
32
34
  }
35
+ if (
36
+ req.method === 'GET' &&
37
+ ['/assets/review.css', '/assets/review.js'].includes(url.pathname)
38
+ ) {
39
+ const css = url.pathname.endsWith('.css')
40
+ return new Response(Bun.file(String(css ? UI_CSS : UI_JS)), {
41
+ headers: {
42
+ 'content-type': css ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
43
+ 'cache-control': 'no-cache',
44
+ },
45
+ })
46
+ }
33
47
  if (req.method === 'GET' && url.pathname === '/api/report') {
34
48
  try {
35
49
  return new Response(buildReport(runDir, loadRun(runDir), loadReview(runDir)), {
@@ -0,0 +1,70 @@
1
+ export const SUMMARY_MARKER = '<!-- design-spine -->'
2
+ export const SUMMARY_ROW_CAP = 50
3
+
4
+ export interface SummaryInput {
5
+ rows: { id: string; result: string; ratio: string }[]
6
+ threshold: number
7
+ policy: string
8
+ gateReasons: string[]
9
+ /** Number of failures the CLI counted; it decides pass or fail. */
10
+ failed: number
11
+ /** Where reviewers find the full report. */
12
+ reportArtifact?: string
13
+ }
14
+
15
+ const RESULTS = [
16
+ 'ok',
17
+ 'CHANGED',
18
+ 'new',
19
+ 'size-changed',
20
+ 'no-capture',
21
+ 'modified-capture',
22
+ 'modified-baseline',
23
+ 'unreadable',
24
+ ]
25
+
26
+ /** Ids and reasons come from user manifests, so neutralise Markdown and HTML. */
27
+ export function escapeMarkdown(text: string): string {
28
+ return text
29
+ .replace(/[\r\n]+/g, ' ')
30
+ .replace(/[&\\|<>`]/g, (c) =>
31
+ c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : `\\${c}`,
32
+ )
33
+ }
34
+
35
+ export function buildSummary(input: SummaryInput): string {
36
+ const { rows, failed } = input
37
+ const counts = new Map<string, number>()
38
+ for (const r of rows) counts.set(r.result, (counts.get(r.result) ?? 0) + 1)
39
+ const kinds = [...RESULTS, ...[...counts.keys()].filter((k) => !RESULTS.includes(k))]
40
+ const parts = kinds
41
+ .filter((k) => (counts.get(k) ?? 0) > 0 || k === 'ok')
42
+ .map((k) => `${counts.get(k) ?? 0} ${escapeMarkdown(k)}`)
43
+ const lines = [
44
+ SUMMARY_MARKER,
45
+ `## Design Spine: ${failed ? 'failed' : 'passed'}`,
46
+ '',
47
+ `${rows.length} tiles (${parts.join(', ')}). Policy: ${escapeMarkdown(input.policy)}, threshold ${input.threshold}.`,
48
+ '',
49
+ ]
50
+ const shown = rows.filter((r) => r.result !== 'ok')
51
+ if (shown.length) {
52
+ lines.push('| Tile | Result | Ratio |', '| --- | --- | --- |')
53
+ for (const r of shown.slice(0, SUMMARY_ROW_CAP))
54
+ lines.push(
55
+ `| ${escapeMarkdown(r.id)} | ${escapeMarkdown(r.result)} | ${escapeMarkdown(r.ratio)} |`,
56
+ )
57
+ if (shown.length > SUMMARY_ROW_CAP) lines.push('', `and ${shown.length - SUMMARY_ROW_CAP} more`)
58
+ lines.push('')
59
+ }
60
+ if (input.gateReasons.length) {
61
+ lines.push('Strict gate reasons:', '')
62
+ for (const reason of input.gateReasons) lines.push(`- ${escapeMarkdown(reason)}`)
63
+ lines.push('')
64
+ }
65
+ lines.push(
66
+ `Full report with screenshots: download the \`${input.reportArtifact ?? 'design-spine-report'}\` artifact from this run.`,
67
+ '',
68
+ )
69
+ return lines.join('\n')
70
+ }