@cellajs/cli 0.6.5 → 0.6.6
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/README.md +3 -3
- package/dist/a11y.js.map +1 -1
- package/dist/bench.js +1 -1
- package/dist/bench.js.map +1 -1
- package/dist/drive.js.map +1 -1
- package/dist/index.js +154 -130
- package/dist/index.js.map +1 -1
- package/dist/screenshots.js.map +1 -1
- package/dist/style.js.map +1 -1
- package/package.json +1 -1
package/dist/drive.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tools/drive.ts","../src/utils/app-facts.ts","../src/utils/env.ts"],"sourcesContent":["import { spawnSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport process from 'node:process';\nimport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\nimport { importFromWorkspace } from '../utils/app-facts';\n\nexport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\n\nexport type Playwright = typeof import('playwright');\n\n/** A signed-in user of the app: where it answers and the cookie that signs a browser or a request in. */\nexport interface Session {\n email: string;\n frontendUrl: string;\n /** The API, a path under the frontend's origin. */\n backendUrl: string;\n /** The app's slug, read off the cookie name; the persisted UI store's key starts with it. */\n slug: string;\n /** In the shape `context.addCookies` takes. */\n cookie: { name: string; value: string; url: string; httpOnly: true; sameSite: 'Strict' };\n /** `name=value`, for a request's cookie header. */\n header: string;\n}\n\nexport interface MintOptions {\n /** The app's root; default the current directory. */\n forkPath?: string;\n /** The environment the mint script runs in: a testbed's names its database. Default this process's. */\n env?: NodeJS.ProcessEnv;\n /** Session lifetime; default 2. */\n hours?: number;\n}\n\n/** Playwright as the app's frontend installed it, so the CLI ships none. */\nexport function loadPlaywright(forkPath = process.cwd()): Promise<Playwright> {\n return importFromWorkspace<Playwright>(forkPath, 'frontend', 'playwright');\n}\n\n/**\n * Checks that Playwright has its Chromium, before anything slow starts.\n * @throws with the install command when it has none\n */\nexport async function requireChromium(forkPath = process.cwd()): Promise<void> {\n const { chromium } = await loadPlaywright(forkPath);\n if (!existsSync(chromium.executablePath())) {\n throw new Error('Playwright has no Chromium yet: run `pnpm --filter frontend exec playwright install chromium`');\n }\n}\n\n/**\n * Signs `email` in through the backend's `session:mint` script, which refuses outside development and test. The script\n * prints `<cookie name>=<signed value>` and a curl line with the API URL, both from the config of the environment it runs\n * in, so a testbed's shifted ports and a linked worktree's are written down nowhere.\n * @throws when the script prints no session, as for an email with no user\n */\nexport function mintSession(email: string, { forkPath = process.cwd(), env, hours = 2 }: MintOptions = {}): Session {\n const minted = spawnSync('pnpm', ['--silent', '--filter', 'backend', 'session:mint', email, String(hours)], {\n cwd: forkPath,\n env,\n encoding: 'utf8',\n });\n const cookie = /^((?:__Host-)?([\\w-]+)-session-v\\d+)=(\\S+)$/m.exec(minted.stdout);\n const api = /^curl (\\S+)\\/me /m.exec(minted.stdout);\n if (!cookie || !api) throw new Error(`could not mint a session for ${email}:\\n${minted.stdout}\\n${minted.stderr}`);\n const frontendUrl = new URL(api[1]).origin;\n return {\n email,\n frontendUrl,\n backendUrl: api[1],\n slug: cookie[2],\n cookie: { name: cookie[1], value: cookie[3], url: frontendUrl, httpOnly: true, sameSite: 'Strict' },\n header: `${cookie[1]}=${cookie[3]}`,\n };\n}\n\n/**\n * Signs a minted session out, so a finished run leaves no live session in the user's sessions list. A JSON content type\n * passes the CSRF check without an Origin header; a stack that is already down leaves the session to expire.\n */\nexport async function signOut(session: Session | null | undefined): Promise<void> {\n if (!session) return;\n const headers = { cookie: session.header, 'content-type': 'application/json' };\n await fetch(`${session.backendUrl}/auth/sign-out`, { method: 'POST', headers }).catch(() => {});\n}\n\nexport interface ContextOptions extends BrowserContextOptions {\n /** Signs the context in. */\n session?: Session;\n /**\n * Fields written into the app's persisted UI store (`<slug>-ui`) before any page script runs, so the first paint is\n * already in the mode they set. Merged into what the store holds: a list joins the stored one, any other value wins.\n */\n uiStore?: { slug: string; state: Record<string, unknown> };\n}\n\n/** A browser context, signed in and with its UI store seeded when asked. */\nexport async function newContext(\n browser: Browser,\n { session, uiStore, ...options }: ContextOptions = {},\n): Promise<BrowserContext> {\n const context = await browser.newContext(options);\n // tsx and esbuild's keepNames wrap named functions in an `__name` helper that a page script does not have\n await context.addInitScript({ content: 'window.__name = (fn) => fn;' });\n if (session) await context.addCookies([session.cookie]);\n if (uiStore) {\n await context.addInitScript(\n ([key, fields]) => {\n const stored = JSON.parse(localStorage.getItem(key) ?? '{}');\n const state = { ...stored.state };\n for (const [name, value] of Object.entries(fields)) {\n const before = state[name];\n state[name] = Array.isArray(value) && Array.isArray(before) ? [...new Set([...before, ...value])] : value;\n }\n // A fresh entry needs the store's own version, or zustand drops it as written by an older build\n localStorage.setItem(key, JSON.stringify({ version: 1, ...stored, state }));\n },\n [`${uiStore.slug}-ui`, uiStore.state] as const,\n );\n }\n return context;\n}\n\n/**\n * Resolves once the page has stopped adding or changing content for `quiet` ms, or after `cap` ms. Lazy sections, query\n * results and opening overlays all show up as DOM changes; the network never goes idle in development.\n */\nexport async function settle(page: Page, { quiet = 400, cap = 3000 } = {}): Promise<void> {\n const waited = page.evaluate(\n ([quietMs, capMs]) =>\n new Promise<void>((resolve) => {\n const done = () => {\n observer.disconnect();\n clearTimeout(timer);\n clearTimeout(limit);\n resolve();\n };\n let timer = setTimeout(done, quietMs);\n const limit = setTimeout(done, capMs);\n const observer = new MutationObserver(() => {\n clearTimeout(timer);\n timer = setTimeout(done, quietMs);\n });\n observer.observe(document, { subtree: true, childList: true, characterData: true });\n }),\n [quiet, cap] as const,\n );\n // A navigation during the wait tears down the page's script context\n await waited.catch(() => page.waitForTimeout(quiet));\n}\n\n/**\n * Opens `url` in a new page and settles once the route has rendered its content.\n * @throws when the route redirects: a check or a shot would judge another page under this one's name\n */\nexport async function openPage(context: BrowserContext, url: string, { timeout = 30_000 } = {}): Promise<Page> {\n const page = await context.newPage();\n await page.goto(url, { waitUntil: 'domcontentloaded', timeout });\n await page.locator('main, [role=\"main\"], #accessibility-content, form, h1, footer').first().waitFor({ timeout });\n await settle(page);\n const landed = new URL(page.url()).pathname;\n const asked = new URL(url).pathname;\n if (landed !== asked) throw new Error(`${asked} redirected to ${landed}`);\n return page;\n}\n","import { execFileSync, spawnSync } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { dirname, join, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { z } from 'zod';\nimport { setEnv } from './env';\n\nconst appConfigSchema = z.object({\n frontendUrl: z.string(),\n backendUrl: z.string(),\n devPortOffset: z.number(),\n services: z.record(\n z.string(),\n z.object({ enabled: z.boolean(), port: z.number(), internalPort: z.number().optional() }),\n ),\n});\n\n/** The part of the app's development config the develop commands read. */\nexport type AppFacts = z.infer<typeof appConfigSchema>;\n\n/** The app's local database helpers (`shared/src/local-database.ts`), as the develop commands call them. */\nexport interface LocalDatabases {\n port: (database: string) => number;\n url: (database: string, role: 'postgres' | 'runtime_role' | 'admin_role') => string;\n /** The env `backend/compose.yaml` reads: the project name and each database's host port. */\n composeEnv: () => Record<string, string>;\n}\n\n/** Node strips the types of a file outside node_modules, so the app's own TS loads without a build or tsx. */\nexport async function importFromApp(forkPath: string, file: string): Promise<Record<string, unknown>> {\n const path = join(forkPath, file);\n if (!existsSync(path)) throw new Error(`not an app built from cella: ${path} is missing`);\n // The app's config reads the mode once, at its first import, and the dev port offset applies in development mode only\n setEnv('NODE_ENV', 'development');\n return import(pathToFileURL(path).href);\n}\n\n/**\n * The app's config in development mode, from its own `shared` package. In a linked worktree the import claims that\n * worktree's port slot, as `pnpm dev` would.\n */\nexport async function loadAppFacts(forkPath: string): Promise<AppFacts> {\n const parsed = appConfigSchema.safeParse((await importFromApp(forkPath, 'shared/index.ts')).appConfig);\n if (!parsed.success)\n throw new Error(`shared/index.ts exports no appConfig this CLI can read: ${parsed.error.message}`);\n return parsed.data;\n}\n\n/** The app's local database helpers. */\nexport async function loadLocalDatabases(forkPath: string): Promise<LocalDatabases> {\n const module = await importFromApp(forkPath, 'shared/src/local-database.ts');\n const { localDatabasePort, localDatabaseUrl, composeEnv } = module;\n if (\n typeof localDatabasePort !== 'function' ||\n typeof localDatabaseUrl !== 'function' ||\n typeof composeEnv !== 'function'\n ) {\n throw new Error('shared/src/local-database.ts lacks localDatabasePort, localDatabaseUrl or composeEnv');\n }\n return {\n port: (database) => localDatabasePort(database),\n url: (database, role) => localDatabaseUrl(database, role),\n composeEnv: () => composeEnv(),\n };\n}\n\n/**\n * A package as the app's `workspace` installed it, so a tool drives the app's own Playwright, TypeScript or Tailwind and\n * the CLI ships none. The package's ESM entry when it has one, else its CommonJS exports.\n * @throws when the workspace does not have the package installed\n */\nexport async function importFromWorkspace<T>(forkPath: string, workspace: string, name: string): Promise<T> {\n const require = createRequire(join(forkPath, workspace, 'package.json'));\n let manifestPath: string | undefined;\n try {\n manifestPath = require.resolve(`${name}/package.json`);\n } catch {\n // A package whose exports leave out package.json resolves by its main entry below\n }\n const root = manifestPath ? JSON.parse(readFileSync(manifestPath, 'utf8')).exports?.['.'] : undefined;\n const esm = typeof root?.import === 'string' ? root.import : root?.import?.default;\n if (manifestPath && typeof esm === 'string') return import(pathToFileURL(join(dirname(manifestPath), esm)).href);\n try {\n return require(name);\n } catch {\n throw new Error(`${name} is not installed in ${workspace}: run \\`pnpm --filter ${workspace} add -D ${name}\\``);\n }\n}\n\n/**\n * A Docker image as a tool runs it, pinned by digest (`name[:tag]@sha256:…`) so every run scans with the same code.\n * Pulls it on first use; the pull shows Docker's own progress.\n * @throws when the reference has no digest, Docker does not answer, or the pull fails\n */\nexport function resolveImage(reference: string): string {\n if (!/@sha256:[0-9a-f]{64}$/.test(reference)) throw new Error(`${reference} is not pinned by digest`);\n if (spawnSync('docker', ['info'], { stdio: 'ignore' }).status !== 0) {\n throw new Error('Docker does not answer: start Docker (or OrbStack) first');\n }\n if (spawnSync('docker', ['image', 'inspect', reference], { stdio: 'ignore' }).status === 0) return reference;\n if (spawnSync('docker', ['pull', reference], { stdio: ['ignore', 'inherit', 'inherit'] }).status !== 0) {\n throw new Error(`could not pull ${reference}`);\n }\n return reference;\n}\n\n/**\n * Where a tool keeps its results and state: `<git dir>/cella-<tool>/` of this checkout, or of the git directory every\n * worktree shares. Inside the git directory nothing shows in `git status`.\n */\nexport function toolDir(forkPath: string, tool: string, scope: 'checkout' | 'repository' = 'checkout'): string {\n const flag = scope === 'checkout' ? '--absolute-git-dir' : '--git-common-dir';\n const gitDir = execFileSync('git', ['rev-parse', flag], { cwd: forkPath, encoding: 'utf8' }).trim();\n return join(resolve(forkPath, gitDir), `cella-${tool}`);\n}\n","import process from 'node:process';\n\n/**\n * Centralized environment access for CLI/runtime integration points.\n */\nexport function getEnv(name: string): string | undefined {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return process.env[name];\n}\n\n/** Whether the variable is set at all, even to an empty string. */\nexport function hasEnv(name: string): boolean {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return name in process.env;\n}\n\n/** A mutable copy of the full environment, for passing to child processes. */\nexport function getEnvSnapshot(): NodeJS.ProcessEnv {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return { ...process.env };\n}\n\n/** Set a variable for this process and the children it spawns from here on. */\nexport function setEnv(name: string, value: string): void {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n process.env[name] = value;\n}\n"],"mappings":"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","names":["spawnSync","existsSync","process","require","process","existsSync","spawnSync","resolve"]}
|
|
1
|
+
{"version":3,"sources":["../src/tools/drive.ts","../src/utils/app-facts.ts","../src/utils/env.ts"],"sourcesContent":["import { spawnSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport process from 'node:process';\nimport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\nimport { importFromWorkspace } from '../utils/app-facts';\n\nexport type { Browser, BrowserContext, BrowserContextOptions, Page } from 'playwright';\n\nexport type Playwright = typeof import('playwright');\n\n/** A signed-in user of the app: where it answers and the cookie that signs a browser or a request in. */\nexport interface Session {\n email: string;\n frontendUrl: string;\n /** The API, a path under the frontend's origin. */\n backendUrl: string;\n /** The app's slug, read off the cookie name; the persisted UI store's key starts with it. */\n slug: string;\n /** In the shape `context.addCookies` takes. */\n cookie: { name: string; value: string; url: string; httpOnly: true; sameSite: 'Strict' };\n /** `name=value`, for a request's cookie header. */\n header: string;\n}\n\nexport interface MintOptions {\n /** The app's root; default the current directory. */\n forkPath?: string;\n /** The environment the mint script runs in: a testbed's names its database. Default this process's. */\n env?: NodeJS.ProcessEnv;\n /** Session lifetime; default 2. */\n hours?: number;\n}\n\n/** Playwright as the app's frontend installed it, so the CLI ships none. */\nexport function loadPlaywright(forkPath = process.cwd()): Promise<Playwright> {\n return importFromWorkspace<Playwright>(forkPath, 'frontend', 'playwright');\n}\n\n/**\n * Checks that Playwright has its Chromium, before anything slow starts.\n * @throws with the install command when it has none\n */\nexport async function requireChromium(forkPath = process.cwd()): Promise<void> {\n const { chromium } = await loadPlaywright(forkPath);\n if (!existsSync(chromium.executablePath())) {\n throw new Error('Playwright has no Chromium yet: run `pnpm --filter frontend exec playwright install chromium`');\n }\n}\n\n/**\n * Signs `email` in through the backend's `session:mint` script, which refuses outside development and test. The script\n * prints `<cookie name>=<signed value>` and a curl line with the API URL, both from the config of the environment it runs\n * in, so a testbed's shifted ports and a linked worktree's are written down nowhere.\n * @throws when the script prints no session, as for an email with no user\n */\nexport function mintSession(email: string, { forkPath = process.cwd(), env, hours = 2 }: MintOptions = {}): Session {\n const minted = spawnSync('pnpm', ['--silent', '--filter', 'backend', 'session:mint', email, String(hours)], {\n cwd: forkPath,\n env,\n encoding: 'utf8',\n });\n const cookie = /^((?:__Host-)?([\\w-]+)-session-v\\d+)=(\\S+)$/m.exec(minted.stdout);\n const api = /^curl (\\S+)\\/me /m.exec(minted.stdout);\n if (!cookie || !api) throw new Error(`could not mint a session for ${email}:\\n${minted.stdout}\\n${minted.stderr}`);\n const frontendUrl = new URL(api[1]).origin;\n return {\n email,\n frontendUrl,\n backendUrl: api[1],\n slug: cookie[2],\n cookie: { name: cookie[1], value: cookie[3], url: frontendUrl, httpOnly: true, sameSite: 'Strict' },\n header: `${cookie[1]}=${cookie[3]}`,\n };\n}\n\n/**\n * Signs a minted session out, so a finished run leaves no live session in the user's sessions list. A JSON content type\n * passes the CSRF check without an Origin header; a stack that is already down leaves the session to expire.\n */\nexport async function signOut(session: Session | null | undefined): Promise<void> {\n if (!session) return;\n const headers = { cookie: session.header, 'content-type': 'application/json' };\n await fetch(`${session.backendUrl}/auth/sign-out`, { method: 'POST', headers }).catch(() => {});\n}\n\nexport interface ContextOptions extends BrowserContextOptions {\n /** Signs the context in. */\n session?: Session;\n /**\n * Fields written into the app's persisted UI store (`<slug>-ui`) before any page script runs, so the first paint is\n * already in the mode they set. Merged into what the store holds: a list joins the stored one, any other value wins.\n */\n uiStore?: { slug: string; state: Record<string, unknown> };\n}\n\n/** A browser context, signed in and with its UI store seeded when asked. */\nexport async function newContext(\n browser: Browser,\n { session, uiStore, ...options }: ContextOptions = {},\n): Promise<BrowserContext> {\n const context = await browser.newContext(options);\n // tsx and esbuild's keepNames wrap named functions in an `__name` helper that a page script does not have\n await context.addInitScript({ content: 'window.__name = (fn) => fn;' });\n if (session) await context.addCookies([session.cookie]);\n if (uiStore) {\n await context.addInitScript(\n ([key, fields]) => {\n const stored = JSON.parse(localStorage.getItem(key) ?? '{}');\n const state = { ...stored.state };\n for (const [name, value] of Object.entries(fields)) {\n const before = state[name];\n state[name] = Array.isArray(value) && Array.isArray(before) ? [...new Set([...before, ...value])] : value;\n }\n // A fresh entry needs the store's own version, or zustand drops it as written by an older build\n localStorage.setItem(key, JSON.stringify({ version: 1, ...stored, state }));\n },\n [`${uiStore.slug}-ui`, uiStore.state] as const,\n );\n }\n return context;\n}\n\n/**\n * Resolves once the page has stopped adding or changing content for `quiet` ms, or after `cap` ms. Lazy sections, query\n * results and opening overlays all show up as DOM changes; the network never goes idle in development.\n */\nexport async function settle(page: Page, { quiet = 400, cap = 3000 } = {}): Promise<void> {\n const waited = page.evaluate(\n ([quietMs, capMs]) =>\n new Promise<void>((resolve) => {\n const done = () => {\n observer.disconnect();\n clearTimeout(timer);\n clearTimeout(limit);\n resolve();\n };\n let timer = setTimeout(done, quietMs);\n const limit = setTimeout(done, capMs);\n const observer = new MutationObserver(() => {\n clearTimeout(timer);\n timer = setTimeout(done, quietMs);\n });\n observer.observe(document, { subtree: true, childList: true, characterData: true });\n }),\n [quiet, cap] as const,\n );\n // A navigation during the wait tears down the page's script context\n await waited.catch(() => page.waitForTimeout(quiet));\n}\n\n/**\n * Opens `url` in a new page and settles once the route has rendered its content.\n * @throws when the route redirects: a check or a shot would judge another page under this one's name\n */\nexport async function openPage(context: BrowserContext, url: string, { timeout = 30_000 } = {}): Promise<Page> {\n const page = await context.newPage();\n await page.goto(url, { waitUntil: 'domcontentloaded', timeout });\n await page.locator('main, [role=\"main\"], #accessibility-content, form, h1, footer').first().waitFor({ timeout });\n await settle(page);\n const landed = new URL(page.url()).pathname;\n const asked = new URL(url).pathname;\n if (landed !== asked) throw new Error(`${asked} redirected to ${landed}`);\n return page;\n}\n","import { execFileSync, spawnSync } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { dirname, join, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { z } from 'zod';\nimport { setEnv } from './env';\n\nconst appConfigSchema = z.object({\n frontendUrl: z.string(),\n backendUrl: z.string(),\n devPortOffset: z.number(),\n services: z.record(\n z.string(),\n z.object({ enabled: z.boolean(), port: z.number(), internalPort: z.number().optional() }),\n ),\n});\n\n/** The part of the app's development config the develop commands read. */\nexport type AppFacts = z.infer<typeof appConfigSchema>;\n\n/** The app's local database helpers (`shared/src/local-database.ts`), as the develop commands call them. */\nexport interface LocalDatabases {\n port: (database: string) => number;\n url: (database: string, role: 'postgres' | 'runtime_role' | 'admin_role') => string;\n /** The env `backend/compose.yaml` reads: the project name and each database's host port. */\n composeEnv: () => Record<string, string>;\n}\n\n/** Node strips the types of a file outside node_modules, so the app's own TS loads without a build or tsx. */\nexport async function importFromApp(forkPath: string, file: string): Promise<Record<string, unknown>> {\n const path = join(forkPath, file);\n if (!existsSync(path)) throw new Error(`not an app built from cella: ${path} is missing`);\n // The app's config reads the mode once, at its first import, and the dev port offset applies in development mode only\n setEnv('NODE_ENV', 'development');\n return import(pathToFileURL(path).href);\n}\n\n/** An app path a template alignment moved: `current`, or `legacy` in an app that has only the old one. */\nexport const movedPath = (forkPath: string, current: string, legacy: string): string =>\n !existsSync(join(forkPath, current)) && existsSync(join(forkPath, legacy)) ? legacy : current;\n\n/**\n * The app's config in development mode, from its own `shared` package. In a linked worktree the import claims that\n * worktree's port slot, as `pnpm dev` would.\n */\nexport async function loadAppFacts(forkPath: string): Promise<AppFacts> {\n const parsed = appConfigSchema.safeParse((await importFromApp(forkPath, 'shared/index.ts')).appConfig);\n if (!parsed.success)\n throw new Error(`shared/index.ts exports no appConfig this CLI can read: ${parsed.error.message}`);\n return parsed.data;\n}\n\n/** The app's local database helpers. */\nexport async function loadLocalDatabases(forkPath: string): Promise<LocalDatabases> {\n const module = await importFromApp(forkPath, 'shared/src/local-database.ts');\n const { localDatabasePort, localDatabaseUrl, composeEnv } = module;\n if (\n typeof localDatabasePort !== 'function' ||\n typeof localDatabaseUrl !== 'function' ||\n typeof composeEnv !== 'function'\n ) {\n throw new Error('shared/src/local-database.ts lacks localDatabasePort, localDatabaseUrl or composeEnv');\n }\n return {\n port: (database) => localDatabasePort(database),\n url: (database, role) => localDatabaseUrl(database, role),\n composeEnv: () => composeEnv(),\n };\n}\n\n/**\n * A package as the app's `workspace` installed it, so a tool drives the app's own Playwright, TypeScript or Tailwind and\n * the CLI ships none. The package's ESM entry when it has one, else its CommonJS exports.\n * @throws when the workspace does not have the package installed\n */\nexport async function importFromWorkspace<T>(forkPath: string, workspace: string, name: string): Promise<T> {\n const require = createRequire(join(forkPath, workspace, 'package.json'));\n let manifestPath: string | undefined;\n try {\n manifestPath = require.resolve(`${name}/package.json`);\n } catch {\n // A package whose exports leave out package.json resolves by its main entry below\n }\n const root = manifestPath ? JSON.parse(readFileSync(manifestPath, 'utf8')).exports?.['.'] : undefined;\n const esm = typeof root?.import === 'string' ? root.import : root?.import?.default;\n if (manifestPath && typeof esm === 'string') return import(pathToFileURL(join(dirname(manifestPath), esm)).href);\n try {\n return require(name);\n } catch {\n throw new Error(`${name} is not installed in ${workspace}: run \\`pnpm --filter ${workspace} add -D ${name}\\``);\n }\n}\n\n/**\n * A Docker image as a tool runs it, pinned by digest (`name[:tag]@sha256:…`) so every run scans with the same code.\n * Pulls it on first use; the pull shows Docker's own progress.\n * @throws when the reference has no digest, Docker does not answer, or the pull fails\n */\nexport function resolveImage(reference: string): string {\n if (!/@sha256:[0-9a-f]{64}$/.test(reference)) throw new Error(`${reference} is not pinned by digest`);\n if (spawnSync('docker', ['info'], { stdio: 'ignore' }).status !== 0) {\n throw new Error('Docker does not answer: start Docker (or OrbStack) first');\n }\n if (spawnSync('docker', ['image', 'inspect', reference], { stdio: 'ignore' }).status === 0) return reference;\n if (spawnSync('docker', ['pull', reference], { stdio: ['ignore', 'inherit', 'inherit'] }).status !== 0) {\n throw new Error(`could not pull ${reference}`);\n }\n return reference;\n}\n\n/**\n * Where a tool keeps its results and state: `<git dir>/cella-<tool>/` of this checkout, or of the git directory every\n * worktree shares. Inside the git directory nothing shows in `git status`.\n */\nexport function toolDir(forkPath: string, tool: string, scope: 'checkout' | 'repository' = 'checkout'): string {\n const flag = scope === 'checkout' ? '--absolute-git-dir' : '--git-common-dir';\n const gitDir = execFileSync('git', ['rev-parse', flag], { cwd: forkPath, encoding: 'utf8' }).trim();\n return join(resolve(forkPath, gitDir), `cella-${tool}`);\n}\n","import process from 'node:process';\n\n/**\n * Centralized environment access for CLI/runtime integration points.\n */\nexport function getEnv(name: string): string | undefined {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return process.env[name];\n}\n\n/** Whether the variable is set at all, even to an empty string. */\nexport function hasEnv(name: string): boolean {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return name in process.env;\n}\n\n/** A mutable copy of the full environment, for passing to child processes. */\nexport function getEnvSnapshot(): NodeJS.ProcessEnv {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n return { ...process.env };\n}\n\n/** Set a variable for this process and the children it spawns from here on. */\nexport function setEnv(name: string, value: string): void {\n // biome-ignore lint/style/noProcessEnv: This module centralizes intentional CLI environment access.\n process.env[name] = value;\n}\n"],"mappings":"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","names":["spawnSync","existsSync","process","require","process","existsSync","spawnSync","resolve"]}
|
package/dist/index.js
CHANGED
|
@@ -49,6 +49,7 @@ async function importFromApp(forkPath, file) {
|
|
|
49
49
|
setEnv("NODE_ENV", "development");
|
|
50
50
|
return import(pathToFileURL(path).href);
|
|
51
51
|
}
|
|
52
|
+
var movedPath = (forkPath, current, legacy) => !existsSync(join(forkPath, current)) && existsSync(join(forkPath, legacy)) ? legacy : current;
|
|
52
53
|
async function loadAppFacts(forkPath) {
|
|
53
54
|
const parsed2 = appConfigSchema.safeParse((await importFromApp(forkPath, "shared/index.ts")).appConfig);
|
|
54
55
|
if (!parsed2.success)
|
|
@@ -233,7 +234,7 @@ import process6 from "process";
|
|
|
233
234
|
// package.json
|
|
234
235
|
var package_default = {
|
|
235
236
|
name: "@cellajs/cli",
|
|
236
|
-
version: "0.6.
|
|
237
|
+
version: "0.6.6",
|
|
237
238
|
private: false,
|
|
238
239
|
license: "MIT",
|
|
239
240
|
description: "CLI for managing your monorepo and upgrading it with upstream Cella.",
|
|
@@ -761,6 +762,15 @@ async function gitMv(cwd, oldPath, newPath) {
|
|
|
761
762
|
await mkdir2(newDir, { recursive: true });
|
|
762
763
|
await git(["mv", "-f", oldPath, newPath], cwd);
|
|
763
764
|
}
|
|
765
|
+
async function moveFromRef(cwd, ref, from, to) {
|
|
766
|
+
const [mode, , blob] = (await git(["ls-tree", ref, "--", from], cwd)).split(/\s+/);
|
|
767
|
+
if (!blob) throw new Error(`${ref} holds no ${from}`);
|
|
768
|
+
await gitRm(cwd, [to], { cached: true });
|
|
769
|
+
await git(["update-index", "--add", "--cacheinfo", `${mode},${blob},${to}`], cwd);
|
|
770
|
+
await mkdir2(dirname3(join3(cwd, to)), { recursive: true });
|
|
771
|
+
await git(["checkout-index", "-f", "--", to], cwd);
|
|
772
|
+
await removeFileFully(cwd, from);
|
|
773
|
+
}
|
|
764
774
|
async function fileExistsInWorktree(cwd, filePath) {
|
|
765
775
|
return existsSync3(join3(cwd, filePath));
|
|
766
776
|
}
|
|
@@ -1373,11 +1383,12 @@ var STARTUP_TIMEOUT_MS = 5 * 6e4;
|
|
|
1373
1383
|
var STOP_TIMEOUT_MS = 1e4;
|
|
1374
1384
|
var DATABASE = "testbed";
|
|
1375
1385
|
var DATABASE_SERVICE = "db_testbed";
|
|
1376
|
-
var
|
|
1386
|
+
var showcaseFile = (forkPath) => movedPath(forkPath, "backend/scripts/seeds/showcase.json", "json/showcase.json");
|
|
1387
|
+
var showcasePath = (forkPath) => join4(forkPath, showcaseFile(forkPath));
|
|
1377
1388
|
var hasShowcase = (forkPath) => existsSync4(showcasePath(forkPath));
|
|
1378
1389
|
function showcaseAdmin(forkPath) {
|
|
1379
1390
|
const { admin } = JSON.parse(readFileSync2(showcasePath(forkPath), "utf8"));
|
|
1380
|
-
if (typeof admin?.email !== "string") throw new Error(
|
|
1391
|
+
if (typeof admin?.email !== "string") throw new Error(`${showcaseFile(forkPath)} names no admin email`);
|
|
1381
1392
|
return admin.email;
|
|
1382
1393
|
}
|
|
1383
1394
|
var shiftPort = (url) => {
|
|
@@ -1666,7 +1677,7 @@ async function withTestbed(forkPath, use, { fresh = false, destructive = false,
|
|
|
1666
1677
|
}
|
|
1667
1678
|
async function runTestbed(forkPath, options) {
|
|
1668
1679
|
if (options.job === "stop") return stopTestbed(await resolveTestbed(forkPath));
|
|
1669
|
-
if (options.showcase && !hasShowcase(forkPath)) throw new Error(
|
|
1680
|
+
if (options.showcase && !hasShowcase(forkPath)) throw new Error(`this app keeps no ${showcaseFile(forkPath)}`);
|
|
1670
1681
|
const data = options.showcase ? "showcase" : "seed";
|
|
1671
1682
|
if (options.command.length > 0) {
|
|
1672
1683
|
return withTestbed(forkPath, (testbed2) => runCommand(forkPath, testbed2.env, options.command), { ...options, data });
|
|
@@ -1701,7 +1712,7 @@ var testbedCommand = {
|
|
|
1701
1712
|
],
|
|
1702
1713
|
options: [
|
|
1703
1714
|
["--fresh", "start from an empty database: migrate and seed it again"],
|
|
1704
|
-
["--showcase", "hold the showcase
|
|
1715
|
+
["--showcase", "hold the showcase seed, which the screenshots shoot, for a review loop"]
|
|
1705
1716
|
],
|
|
1706
1717
|
passthrough: [
|
|
1707
1718
|
"command",
|
|
@@ -1716,8 +1727,8 @@ var testbedCommand = {
|
|
|
1716
1727
|
};
|
|
1717
1728
|
|
|
1718
1729
|
// src/tools/a11y/audit.ts
|
|
1719
|
-
import { mkdirSync as mkdirSync3, statSync, writeFileSync as
|
|
1720
|
-
import { join as
|
|
1730
|
+
import { mkdirSync as mkdirSync3, statSync, writeFileSync as writeFileSync5 } from "fs";
|
|
1731
|
+
import { join as join10 } from "path";
|
|
1721
1732
|
|
|
1722
1733
|
// src/tools/a11y/axe-scan.ts
|
|
1723
1734
|
import axe from "axe-core";
|
|
@@ -2387,8 +2398,97 @@ function recordDecisions(forkPath, decisions) {
|
|
|
2387
2398
|
}
|
|
2388
2399
|
|
|
2389
2400
|
// src/tools/a11y/probes/page-structure.ts
|
|
2390
|
-
import { readFileSync as
|
|
2401
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
2402
|
+
import { join as join8 } from "path";
|
|
2403
|
+
|
|
2404
|
+
// src/tools/i18n/locales.ts
|
|
2405
|
+
import { existsSync as existsSync7, readdirSync, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
2391
2406
|
import { join as join7 } from "path";
|
|
2407
|
+
var localesDir = (forkPath) => movedPath(forkPath, "shared/locales", "locales");
|
|
2408
|
+
var MANIFEST = "namespaces.json";
|
|
2409
|
+
var SOURCE_LANGUAGE = "en";
|
|
2410
|
+
var entryPattern = /^(\s+)("(?:[^"\\]|\\.)*")\s*:\s*("(?:[^"\\]|\\.)*"),?\s*$/;
|
|
2411
|
+
function parseLocaleFile(path, text2) {
|
|
2412
|
+
if (text2.trim() === "{}") return { path, indent: " ", entries: [] };
|
|
2413
|
+
const lines = text2.replace(/\n$/, "").split("\n");
|
|
2414
|
+
if (lines[0] !== "{" || lines.at(-1) !== "}") return "not one JSON object with one entry per line";
|
|
2415
|
+
const entries = [];
|
|
2416
|
+
let indent = " ";
|
|
2417
|
+
for (const line of lines.slice(1, -1)) {
|
|
2418
|
+
const match = entryPattern.exec(line);
|
|
2419
|
+
if (!match) return `cannot read the line ${JSON.stringify(line.trim())}`;
|
|
2420
|
+
indent = match[1];
|
|
2421
|
+
entries.push({ key: JSON.parse(match[2]), value: JSON.parse(match[3]), raw: [match[2], match[3]] });
|
|
2422
|
+
}
|
|
2423
|
+
return { path, indent, entries };
|
|
2424
|
+
}
|
|
2425
|
+
function serializeLocaleFile(file) {
|
|
2426
|
+
if (file.entries.length === 0) return "{}\n";
|
|
2427
|
+
return `{
|
|
2428
|
+
${file.entries.map(({ raw }) => `${file.indent}${raw[0]}: ${raw[1]}`).join(",\n")}
|
|
2429
|
+
}
|
|
2430
|
+
`;
|
|
2431
|
+
}
|
|
2432
|
+
function writeLocaleFile(forkPath, file) {
|
|
2433
|
+
writeFileSync3(join7(forkPath, file.path), serializeLocaleFile(file));
|
|
2434
|
+
}
|
|
2435
|
+
var textsOf = (file) => Object.fromEntries((file?.entries ?? []).map(({ key, value }) => [key, value]));
|
|
2436
|
+
var readJsonObject = (path) => {
|
|
2437
|
+
const parsed2 = JSON.parse(readFileSync4(path, "utf8"));
|
|
2438
|
+
if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) throw new Error(`${path} is not a JSON object`);
|
|
2439
|
+
return Object.fromEntries(Object.entries(parsed2));
|
|
2440
|
+
};
|
|
2441
|
+
function readLocales(forkPath) {
|
|
2442
|
+
const localesPath = localesDir(forkPath);
|
|
2443
|
+
const dir = join7(forkPath, localesPath);
|
|
2444
|
+
if (!existsSync7(join7(dir, SOURCE_LANGUAGE))) throw new Error(`no ${localesPath}/${SOURCE_LANGUAGE}/ in ${forkPath}`);
|
|
2445
|
+
const languages = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).sort((a, b) => a === SOURCE_LANGUAGE ? -1 : b === SOURCE_LANGUAGE ? 1 : a.localeCompare(b));
|
|
2446
|
+
const files2 = /* @__PURE__ */ new Map();
|
|
2447
|
+
for (const language2 of languages) {
|
|
2448
|
+
for (const name of readdirSync(join7(dir, language2)).filter((name2) => name2.endsWith(".json"))) {
|
|
2449
|
+
const path = `${localesPath}/${language2}/${name}`;
|
|
2450
|
+
const parsed2 = parseLocaleFile(path, readFileSync4(join7(forkPath, path), "utf8"));
|
|
2451
|
+
if (typeof parsed2 === "string") throw new Error(`${path}: ${parsed2}; pnpm lint:fix writes one entry per line`);
|
|
2452
|
+
files2.set(`${language2}/${name.slice(0, -".json".length)}`, parsed2);
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
const manifestPath = join7(dir, MANIFEST);
|
|
2456
|
+
if (!existsSync7(manifestPath)) throw new Error(`no ${localesPath}/${MANIFEST}: upgrade the app from cella first`);
|
|
2457
|
+
const merged = readJsonObject(manifestPath);
|
|
2458
|
+
const namespaces = /* @__PURE__ */ new Map();
|
|
2459
|
+
for (const [namespace, sources] of Object.entries(merged)) {
|
|
2460
|
+
if (!Array.isArray(sources) || !sources.every((source) => typeof source === "string")) {
|
|
2461
|
+
throw new Error(`${localesPath}/${MANIFEST}: ${namespace} must list file names`);
|
|
2462
|
+
}
|
|
2463
|
+
namespaces.set(namespace, sources);
|
|
2464
|
+
}
|
|
2465
|
+
const mergedFiles = new Set([...namespaces.values()].flat());
|
|
2466
|
+
const ownFiles = [...new Set([...files2.keys()].map((key) => key.split("/")[1]))].filter(
|
|
2467
|
+
(file) => !mergedFiles.has(file)
|
|
2468
|
+
);
|
|
2469
|
+
for (const file of ownFiles.sort()) namespaces.set(file, [file]);
|
|
2470
|
+
return { dir: localesPath, languages, namespaces, files: files2 };
|
|
2471
|
+
}
|
|
2472
|
+
function namespaceTexts(locales, language2, namespace) {
|
|
2473
|
+
const sources = locales.namespaces.get(namespace) ?? [];
|
|
2474
|
+
return Object.assign({}, ...sources.map((file) => textsOf(locales.files.get(`${language2}/${file}`))));
|
|
2475
|
+
}
|
|
2476
|
+
var lineOf = (file, key) => file.entries.findIndex((entry) => entry.key === key) + 2;
|
|
2477
|
+
var pluralSuffix = /_(ordinal_)?(zero|one|two|few|many|other)$/;
|
|
2478
|
+
function pluralForm(key) {
|
|
2479
|
+
const match = pluralSuffix.exec(key);
|
|
2480
|
+
if (!match) return void 0;
|
|
2481
|
+
return { base: key.slice(0, match.index), category: match[2], ordinal: !!match[1] };
|
|
2482
|
+
}
|
|
2483
|
+
var withPluralForms = (key) => [
|
|
2484
|
+
key,
|
|
2485
|
+
...["zero", "one", "two", "few", "many", "other"].flatMap((category) => [
|
|
2486
|
+
`${key}_${category}`,
|
|
2487
|
+
`${key}_ordinal_${category}`
|
|
2488
|
+
])
|
|
2489
|
+
];
|
|
2490
|
+
|
|
2491
|
+
// src/tools/a11y/probes/page-structure.ts
|
|
2392
2492
|
var titles = { criteria: ["2.4.2"], check: "probe:titles", what: "A distinct, descriptive page title" };
|
|
2393
2493
|
var language = {
|
|
2394
2494
|
criteria: ["3.1.1"],
|
|
@@ -2422,7 +2522,7 @@ var pageStructure = async (page, state, { app }) => {
|
|
|
2422
2522
|
var languageSwitch = async (page, state, { app, forkPath }) => {
|
|
2423
2523
|
if (state.open) return [];
|
|
2424
2524
|
const names = JSON.parse(
|
|
2425
|
-
|
|
2525
|
+
readFileSync5(join8(forkPath, localesDir(forkPath), app.defaultLanguage, "common.json"), "utf8")
|
|
2426
2526
|
);
|
|
2427
2527
|
const findings2 = [];
|
|
2428
2528
|
for (const other of app.languages.filter((lng) => lng !== app.defaultLanguage)) {
|
|
@@ -2756,15 +2856,15 @@ var inputs = async (page, state) => {
|
|
|
2756
2856
|
};
|
|
2757
2857
|
|
|
2758
2858
|
// src/tools/a11y/review-packet.ts
|
|
2759
|
-
import { mkdirSync as mkdirSync2, rmSync as rmSync2, writeFileSync as
|
|
2760
|
-
import { join as
|
|
2761
|
-
var packetDir = (run, stateId) =>
|
|
2859
|
+
import { mkdirSync as mkdirSync2, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
2860
|
+
import { join as join9 } from "path";
|
|
2861
|
+
var packetDir = (run, stateId) => join9(run.resultsDir, "review", stateId);
|
|
2762
2862
|
function writePacketFile(run, stateId, name, content) {
|
|
2763
2863
|
mkdirSync2(packetDir(run, stateId), { recursive: true });
|
|
2764
|
-
|
|
2864
|
+
writeFileSync4(join9(packetDir(run, stateId), name), content);
|
|
2765
2865
|
}
|
|
2766
2866
|
function removePacketFile(run, stateId, name) {
|
|
2767
|
-
rmSync2(
|
|
2867
|
+
rmSync2(join9(packetDir(run, stateId), name), { force: true });
|
|
2768
2868
|
}
|
|
2769
2869
|
function pageFacts() {
|
|
2770
2870
|
const text2 = (el) => (el?.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
@@ -3225,7 +3325,7 @@ async function runAudit(run, scope, testbed, options) {
|
|
|
3225
3325
|
const startedAt = Date.now();
|
|
3226
3326
|
const email = options.email ?? testbed.env.ADMIN_EMAIL;
|
|
3227
3327
|
if (!email) throw new Error("no audit user: pass --email, or set ADMIN_EMAIL in backend/.env");
|
|
3228
|
-
const builtAt = statSync(
|
|
3328
|
+
const builtAt = statSync(join10(run.forkPath, "frontend/dist/index.html"), { throwIfNoEntry: false })?.mtime;
|
|
3229
3329
|
printStep("auditing", `${testbed.frontendUrl}, frontend build of ${builtAt?.toLocaleString() ?? "unknown date"}`);
|
|
3230
3330
|
const states = options.states.length > 0 ? scope.states.filter((state) => options.states.includes(state.id)) : scope.states;
|
|
3231
3331
|
const session = await startAudit(run.forkPath, scope, { email, env: testbed.env });
|
|
@@ -3265,12 +3365,12 @@ async function runAudit(run, scope, testbed, options) {
|
|
|
3265
3365
|
mkdirSync3(run.resultsDir, { recursive: true });
|
|
3266
3366
|
const axe2 = summarizeAxe(scans);
|
|
3267
3367
|
for (const [id, items] of axe2.evidence) for (const item of items) evidence.add([id], item);
|
|
3268
|
-
|
|
3368
|
+
writeFileSync5(join10(run.resultsDir, "axe.json"), `${JSON.stringify({ ...axe2.report, errors }, null, 2)}
|
|
3269
3369
|
`);
|
|
3270
3370
|
evidence.addFindings(acrossStates(findings2));
|
|
3271
3371
|
runCodeChecks(evidence, run, scope.sources);
|
|
3272
3372
|
const skipped2 = Object.keys(errors).length;
|
|
3273
|
-
const target = options.states.length > 0 || skipped2 ?
|
|
3373
|
+
const target = options.states.length > 0 || skipped2 ? join10(run.resultsDir, "ledger-partial.json") : join10(run.forkPath, ledgerFile);
|
|
3274
3374
|
const ledger = writeLedger(
|
|
3275
3375
|
run.forkPath,
|
|
3276
3376
|
evidence.byCriterion,
|
|
@@ -3294,10 +3394,10 @@ async function runAudit(run, scope, testbed, options) {
|
|
|
3294
3394
|
}
|
|
3295
3395
|
|
|
3296
3396
|
// src/tools/a11y/decide.ts
|
|
3297
|
-
import { readFileSync as
|
|
3397
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
3298
3398
|
import { resolve as resolve4 } from "path";
|
|
3299
3399
|
function runDecide(forkPath, { file, decision }) {
|
|
3300
|
-
const input2 = file ? JSON.parse(
|
|
3400
|
+
const input2 = file ? JSON.parse(readFileSync6(resolve4(file), "utf8")) : [decision];
|
|
3301
3401
|
if (!Array.isArray(input2)) throw new Error(`${file} holds no list of decisions`);
|
|
3302
3402
|
const decisions = input2.map(parseDecision);
|
|
3303
3403
|
const ledger = recordDecisions(forkPath, decisions);
|
|
@@ -3311,8 +3411,8 @@ function runDecide(forkPath, { file, decision }) {
|
|
|
3311
3411
|
|
|
3312
3412
|
// src/tools/a11y/report.ts
|
|
3313
3413
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
3314
|
-
import { copyFileSync, mkdirSync as mkdirSync4, writeFileSync as
|
|
3315
|
-
import { dirname as dirname4, join as
|
|
3414
|
+
import { copyFileSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
3415
|
+
import { dirname as dirname4, join as join11 } from "path";
|
|
3316
3416
|
var statusLabel = {
|
|
3317
3417
|
supports: "Supports",
|
|
3318
3418
|
"partially-supports": "Partially Supports",
|
|
@@ -3431,7 +3531,7 @@ Not evaluated.
|
|
|
3431
3531
|
|
|
3432
3532
|
This report describes the product as tested on the report date. Accessibility can change as the product changes.
|
|
3433
3533
|
`;
|
|
3434
|
-
const tracked = spawnSync5("git", ["ls-files", "frontend/src", "backend/src", "locales"], {
|
|
3534
|
+
const tracked = spawnSync5("git", ["ls-files", "frontend/src", "backend/src", "shared/locales", "locales"], {
|
|
3435
3535
|
cwd: forkPath,
|
|
3436
3536
|
encoding: "utf8"
|
|
3437
3537
|
}).stdout.split("\n");
|
|
@@ -3458,7 +3558,7 @@ NVDA and Firefox on Windows, keyboard only, in light and dark mode.
|
|
|
3458
3558
|
|
|
3459
3559
|
## Confirm: decided by an agent (${byAgent.length})
|
|
3460
3560
|
|
|
3461
|
-
The agent judged these from the review packets in \`${
|
|
3561
|
+
The agent judged these from the review packets in \`${join11(resultsDir, "review")}/\`. Check its evidence, then record your own
|
|
3462
3562
|
decision; a wrong Supports here ends up in the published report.
|
|
3463
3563
|
|
|
3464
3564
|
${byAgent.map((r) => {
|
|
@@ -3502,11 +3602,11 @@ ${failing.map((r) => {
|
|
|
3502
3602
|
}).join("\n")}
|
|
3503
3603
|
`;
|
|
3504
3604
|
mkdirSync4(resultsDir, { recursive: true });
|
|
3505
|
-
|
|
3506
|
-
|
|
3605
|
+
writeFileSync6(join11(resultsDir, "vpat-draft.md"), vpat);
|
|
3606
|
+
writeFileSync6(join11(resultsDir, "manual-pass.md"), checklist);
|
|
3507
3607
|
const html = toHtml(vpat, `${app.name} Accessibility Conformance Report`);
|
|
3508
|
-
const pdfPath =
|
|
3509
|
-
|
|
3608
|
+
const pdfPath = join11(resultsDir, "conformance-report.pdf");
|
|
3609
|
+
writeFileSync6(join11(resultsDir, "conformance-report.html"), html);
|
|
3510
3610
|
const { chromium } = await loadPlaywright(forkPath);
|
|
3511
3611
|
const browser = await chromium.launch();
|
|
3512
3612
|
try {
|
|
@@ -3526,12 +3626,12 @@ ${failing.map((r) => {
|
|
|
3526
3626
|
if (unconfirmed && !draft) {
|
|
3527
3627
|
const ids = [...open, ...byAgent].map((r) => r.id).join(", ");
|
|
3528
3628
|
throw new Error(
|
|
3529
|
-
`not published: ${open.length} criteria are open and ${byAgent.length} are decided by an agent only (${ids}). See ${
|
|
3629
|
+
`not published: ${open.length} criteria are open and ${byAgent.length} are decided by an agent only (${ids}). See ${join11(resultsDir, "manual-pass.md")}, or add --draft to publish it as a draft`
|
|
3530
3630
|
);
|
|
3531
3631
|
}
|
|
3532
3632
|
const publicPath = "frontend/public/static/common/accessibility-conformance-report.pdf";
|
|
3533
|
-
mkdirSync4(dirname4(
|
|
3534
|
-
copyFileSync(pdfPath,
|
|
3633
|
+
mkdirSync4(dirname4(join11(forkPath, publicPath)), { recursive: true });
|
|
3634
|
+
copyFileSync(pdfPath, join11(forkPath, publicPath));
|
|
3535
3635
|
const count3 = (status) => ledger.criteria.filter((r) => r.status === status).length;
|
|
3536
3636
|
const review = {
|
|
3537
3637
|
standard: ledger.standard,
|
|
@@ -3638,8 +3738,8 @@ var a11yCommand = {
|
|
|
3638
3738
|
};
|
|
3639
3739
|
|
|
3640
3740
|
// src/tools/bench/command.ts
|
|
3641
|
-
import { existsSync as
|
|
3642
|
-
import { basename, join as
|
|
3741
|
+
import { existsSync as existsSync8, readdirSync as readdirSync2 } from "fs";
|
|
3742
|
+
import { basename, join as join12 } from "path";
|
|
3643
3743
|
|
|
3644
3744
|
// src/tools/bench/request.ts
|
|
3645
3745
|
var BENCH_REQUEST_ENV = "CELLA_BENCH";
|
|
@@ -3648,11 +3748,11 @@ var BENCH_REQUEST_ENV = "CELLA_BENCH";
|
|
|
3648
3748
|
var BENCH_DIR = "backend/bench";
|
|
3649
3749
|
var ENTRY_COMMAND = ["pnpm", "exec", "tsx", "--no-experimental-webstorage", "bench/run.ts"];
|
|
3650
3750
|
async function runBench(forkPath, options) {
|
|
3651
|
-
if (!
|
|
3751
|
+
if (!existsSync8(join12(forkPath, BENCH_DIR, "run.ts"))) {
|
|
3652
3752
|
throw new Error(`${BENCH_DIR}/run.ts is missing: upgrade the app from cella first`);
|
|
3653
3753
|
}
|
|
3654
|
-
const scenariosDir =
|
|
3655
|
-
const names =
|
|
3754
|
+
const scenariosDir = join12(forkPath, BENCH_DIR, "scenarios");
|
|
3755
|
+
const names = readdirSync2(scenariosDir).filter((file) => file.endsWith(".ts")).map((file) => basename(file, ".ts")).sort();
|
|
3656
3756
|
if (options.scenario && !names.includes(options.scenario)) {
|
|
3657
3757
|
throw new Error(`unknown scenario '${options.scenario}'. expected one of: ${names.join(", ")}`);
|
|
3658
3758
|
}
|
|
@@ -3664,7 +3764,7 @@ async function runBench(forkPath, options) {
|
|
|
3664
3764
|
scenariosDir,
|
|
3665
3765
|
dataDir: toolDir(forkPath, "bench", "repository")
|
|
3666
3766
|
};
|
|
3667
|
-
const run = (env) => runCommand(
|
|
3767
|
+
const run = (env) => runCommand(join12(forkPath, "backend"), { ...env, [BENCH_REQUEST_ENV]: JSON.stringify(request) }, ENTRY_COMMAND);
|
|
3668
3768
|
return options.isolated ? withTestbed(forkPath, (testbed) => run(testbed.env)) : run(getEnvSnapshot());
|
|
3669
3769
|
}
|
|
3670
3770
|
var benchCommand = {
|
|
@@ -3703,98 +3803,12 @@ import { spawnSync as spawnSync6 } from "child_process";
|
|
|
3703
3803
|
import { existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
3704
3804
|
import { join as join14 } from "path";
|
|
3705
3805
|
|
|
3706
|
-
// src/tools/i18n/locales.ts
|
|
3707
|
-
import { existsSync as existsSync8, readdirSync as readdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
|
|
3708
|
-
import { join as join12 } from "path";
|
|
3709
|
-
var LOCALES_DIR = "locales";
|
|
3710
|
-
var MANIFEST = "namespaces.json";
|
|
3711
|
-
var SOURCE_LANGUAGE = "en";
|
|
3712
|
-
var entryPattern = /^(\s+)("(?:[^"\\]|\\.)*")\s*:\s*("(?:[^"\\]|\\.)*"),?\s*$/;
|
|
3713
|
-
function parseLocaleFile(path, text2) {
|
|
3714
|
-
if (text2.trim() === "{}") return { path, indent: " ", entries: [] };
|
|
3715
|
-
const lines = text2.replace(/\n$/, "").split("\n");
|
|
3716
|
-
if (lines[0] !== "{" || lines.at(-1) !== "}") return "not one JSON object with one entry per line";
|
|
3717
|
-
const entries = [];
|
|
3718
|
-
let indent = " ";
|
|
3719
|
-
for (const line of lines.slice(1, -1)) {
|
|
3720
|
-
const match = entryPattern.exec(line);
|
|
3721
|
-
if (!match) return `cannot read the line ${JSON.stringify(line.trim())}`;
|
|
3722
|
-
indent = match[1];
|
|
3723
|
-
entries.push({ key: JSON.parse(match[2]), value: JSON.parse(match[3]), raw: [match[2], match[3]] });
|
|
3724
|
-
}
|
|
3725
|
-
return { path, indent, entries };
|
|
3726
|
-
}
|
|
3727
|
-
function serializeLocaleFile(file) {
|
|
3728
|
-
if (file.entries.length === 0) return "{}\n";
|
|
3729
|
-
return `{
|
|
3730
|
-
${file.entries.map(({ raw }) => `${file.indent}${raw[0]}: ${raw[1]}`).join(",\n")}
|
|
3731
|
-
}
|
|
3732
|
-
`;
|
|
3733
|
-
}
|
|
3734
|
-
function writeLocaleFile(forkPath, file) {
|
|
3735
|
-
writeFileSync6(join12(forkPath, file.path), serializeLocaleFile(file));
|
|
3736
|
-
}
|
|
3737
|
-
var textsOf = (file) => Object.fromEntries((file?.entries ?? []).map(({ key, value }) => [key, value]));
|
|
3738
|
-
var readJsonObject = (path) => {
|
|
3739
|
-
const parsed2 = JSON.parse(readFileSync6(path, "utf8"));
|
|
3740
|
-
if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) throw new Error(`${path} is not a JSON object`);
|
|
3741
|
-
return Object.fromEntries(Object.entries(parsed2));
|
|
3742
|
-
};
|
|
3743
|
-
function readLocales(forkPath) {
|
|
3744
|
-
const dir = join12(forkPath, LOCALES_DIR);
|
|
3745
|
-
if (!existsSync8(join12(dir, SOURCE_LANGUAGE))) throw new Error(`no ${LOCALES_DIR}/${SOURCE_LANGUAGE}/ in ${forkPath}`);
|
|
3746
|
-
const languages = readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).sort((a, b) => a === SOURCE_LANGUAGE ? -1 : b === SOURCE_LANGUAGE ? 1 : a.localeCompare(b));
|
|
3747
|
-
const files2 = /* @__PURE__ */ new Map();
|
|
3748
|
-
for (const language2 of languages) {
|
|
3749
|
-
for (const name of readdirSync2(join12(dir, language2)).filter((name2) => name2.endsWith(".json"))) {
|
|
3750
|
-
const path = `${LOCALES_DIR}/${language2}/${name}`;
|
|
3751
|
-
const parsed2 = parseLocaleFile(path, readFileSync6(join12(forkPath, path), "utf8"));
|
|
3752
|
-
if (typeof parsed2 === "string") throw new Error(`${path}: ${parsed2}; pnpm lint:fix writes one entry per line`);
|
|
3753
|
-
files2.set(`${language2}/${name.slice(0, -".json".length)}`, parsed2);
|
|
3754
|
-
}
|
|
3755
|
-
}
|
|
3756
|
-
const manifestPath = join12(dir, MANIFEST);
|
|
3757
|
-
if (!existsSync8(manifestPath)) throw new Error(`no ${LOCALES_DIR}/${MANIFEST}: upgrade the app from cella first`);
|
|
3758
|
-
const merged = readJsonObject(manifestPath);
|
|
3759
|
-
const namespaces = /* @__PURE__ */ new Map();
|
|
3760
|
-
for (const [namespace, sources] of Object.entries(merged)) {
|
|
3761
|
-
if (!Array.isArray(sources) || !sources.every((source) => typeof source === "string")) {
|
|
3762
|
-
throw new Error(`${LOCALES_DIR}/${MANIFEST}: ${namespace} must list file names`);
|
|
3763
|
-
}
|
|
3764
|
-
namespaces.set(namespace, sources);
|
|
3765
|
-
}
|
|
3766
|
-
const mergedFiles = new Set([...namespaces.values()].flat());
|
|
3767
|
-
const ownFiles = [...new Set([...files2.keys()].map((key) => key.split("/")[1]))].filter(
|
|
3768
|
-
(file) => !mergedFiles.has(file)
|
|
3769
|
-
);
|
|
3770
|
-
for (const file of ownFiles.sort()) namespaces.set(file, [file]);
|
|
3771
|
-
return { languages, namespaces, files: files2 };
|
|
3772
|
-
}
|
|
3773
|
-
function namespaceTexts(locales, language2, namespace) {
|
|
3774
|
-
const sources = locales.namespaces.get(namespace) ?? [];
|
|
3775
|
-
return Object.assign({}, ...sources.map((file) => textsOf(locales.files.get(`${language2}/${file}`))));
|
|
3776
|
-
}
|
|
3777
|
-
var lineOf = (file, key) => file.entries.findIndex((entry) => entry.key === key) + 2;
|
|
3778
|
-
var pluralSuffix = /_(ordinal_)?(zero|one|two|few|many|other)$/;
|
|
3779
|
-
function pluralForm(key) {
|
|
3780
|
-
const match = pluralSuffix.exec(key);
|
|
3781
|
-
if (!match) return void 0;
|
|
3782
|
-
return { base: key.slice(0, match.index), category: match[2], ordinal: !!match[1] };
|
|
3783
|
-
}
|
|
3784
|
-
var withPluralForms = (key) => [
|
|
3785
|
-
key,
|
|
3786
|
-
...["zero", "one", "two", "few", "many", "other"].flatMap((category) => [
|
|
3787
|
-
`${key}_${category}`,
|
|
3788
|
-
`${key}_ordinal_${category}`
|
|
3789
|
-
])
|
|
3790
|
-
];
|
|
3791
|
-
|
|
3792
3806
|
// src/tools/i18n/scan.ts
|
|
3793
3807
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
3794
3808
|
import { readFileSync as readFileSync7, statSync as statSync2 } from "fs";
|
|
3795
3809
|
import { dirname as dirname5, join as join13 } from "path";
|
|
3796
3810
|
var codeFile = /\.(?:[cm]?[jt]sx?)$/;
|
|
3797
|
-
var skipped = (path) => path.endsWith(".d.ts") || path.startsWith("locales/") || path.startsWith("cella/alignments/") || path.split("/").some((segment) => segment === "node_modules" || segment === "gen" || segment.includes(".gen."));
|
|
3811
|
+
var skipped = (path) => path.endsWith(".d.ts") || path.startsWith("locales/") || path.startsWith("shared/locales/") || path.startsWith("cella/alignments/") || path.split("/").some((segment) => segment === "node_modules" || segment === "gen" || segment.includes(".gen."));
|
|
3798
3812
|
var MAX_FILE_BYTES = 1e6;
|
|
3799
3813
|
var MAX_COMBINATIONS = 500;
|
|
3800
3814
|
var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -4211,10 +4225,10 @@ function printList(title, items) {
|
|
|
4211
4225
|
console.info(` ${colors_default.dim(`${items.slice(0, LIST_MAX).join(", ")}${more}`)}`);
|
|
4212
4226
|
}
|
|
4213
4227
|
var describe = ({ key, missing, extra }) => [key, ...missing.map((name) => `-${name}`), ...extra.map((name) => `+${name}`)].join(" ");
|
|
4214
|
-
function printStatus(report) {
|
|
4228
|
+
function printStatus(report, dir) {
|
|
4215
4229
|
const label = ({ name, files: files2 }) => files2.length > 1 || files2[0] !== name ? `${name} (${files2.join(" + ")})` : name;
|
|
4216
4230
|
console.info(
|
|
4217
|
-
`${colors_default.bold(`${
|
|
4231
|
+
`${colors_default.bold(`${dir}/`)} ${colors_default.dim(`${report.languages.join(", ")} \xB7 namespaces ${report.namespaces.map(label).join(", ")}`)}`
|
|
4218
4232
|
);
|
|
4219
4233
|
console.info();
|
|
4220
4234
|
console.info(colors_default.dim(row2(columns.map(([title]) => title))));
|
|
@@ -4286,9 +4300,10 @@ function printStatus(report) {
|
|
|
4286
4300
|
console.info(` ${colors_default.dim("missing and same-as-English keys: --json lists them \xB7 cella i18n unused")}`);
|
|
4287
4301
|
}
|
|
4288
4302
|
function runStatus(forkPath, json) {
|
|
4289
|
-
const
|
|
4303
|
+
const locales = readLocales(forkPath);
|
|
4304
|
+
const report = buildStatus(locales);
|
|
4290
4305
|
if (json) writeStdout(JSON.stringify(report, null, 2));
|
|
4291
|
-
else printStatus(report);
|
|
4306
|
+
else printStatus(report, locales.dir);
|
|
4292
4307
|
return defectCount(report) === 0 ? 0 : 1;
|
|
4293
4308
|
}
|
|
4294
4309
|
|
|
@@ -5779,7 +5794,9 @@ var alignmentExempt = [
|
|
|
5779
5794
|
"json/",
|
|
5780
5795
|
"locales/",
|
|
5781
5796
|
"sdk/gen",
|
|
5782
|
-
"shared/config"
|
|
5797
|
+
"shared/config",
|
|
5798
|
+
"shared/json/",
|
|
5799
|
+
"shared/locales/"
|
|
5783
5800
|
];
|
|
5784
5801
|
function ownedPaths(configSource) {
|
|
5785
5802
|
const lists = [...configSource.matchAll(/\b(?:ignored|pinned): \[([\s\S]*?)\n {4}\]/g)].map((match) => match[1]);
|
|
@@ -7615,8 +7632,9 @@ async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predic
|
|
|
7615
7632
|
const forkModifiedOld = forkOldHash !== baseOldHash;
|
|
7616
7633
|
const oldPathPinned = predicates.isPinned(oldPath);
|
|
7617
7634
|
let upstreamChanged2;
|
|
7618
|
-
if (fileIsPinned || oldPathPinned || predicates.isIgnored(oldPath)) {
|
|
7635
|
+
if (fileIsPinned || fileIsIgnored || oldPathPinned || predicates.isIgnored(oldPath)) {
|
|
7619
7636
|
status = "pinned";
|
|
7637
|
+
if (oldPathInFork) renamedFrom = oldPath;
|
|
7620
7638
|
if (forkModifiedOld && upstreamHash !== baseOldHash) {
|
|
7621
7639
|
upstreamChanged2 = true;
|
|
7622
7640
|
renamedProtected.add(filePath);
|
|
@@ -7772,9 +7790,11 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
|
|
|
7772
7790
|
);
|
|
7773
7791
|
const batchRestorePaths = [];
|
|
7774
7792
|
const batchRemovePaths = [];
|
|
7793
|
+
const movedProtected = [];
|
|
7775
7794
|
for (const file of analyzedFiles) {
|
|
7776
7795
|
if (file.status === "identical" || file.status === "ahead") continue;
|
|
7777
|
-
if (file.
|
|
7796
|
+
if (file.status === "pinned" && file.renamedFrom) movedProtected.push({ from: file.renamedFrom, to: file.path });
|
|
7797
|
+
else if (file.isIgnored || file.isPinned) {
|
|
7778
7798
|
(file.existsInFork ? batchRestorePaths : batchRemovePaths).push(file.path);
|
|
7779
7799
|
}
|
|
7780
7800
|
}
|
|
@@ -7791,6 +7811,10 @@ async function applyDirectMerge(forkPath, upstreamRef, mergeBaseRef, config, onP
|
|
|
7791
7811
|
await removeFileFromWorktree(forkPath, filePath);
|
|
7792
7812
|
}
|
|
7793
7813
|
}
|
|
7814
|
+
for (const { from, to } of movedProtected) {
|
|
7815
|
+
onProgress?.(`\u2192 ${from} \u2192 ${to}: keeping your copy (moved in upstream)`);
|
|
7816
|
+
await moveFromRef(forkPath, "HEAD", from, to);
|
|
7817
|
+
}
|
|
7794
7818
|
for (const file of analyzedFiles) {
|
|
7795
7819
|
const { path: filePath, status, existsInFork } = file;
|
|
7796
7820
|
if (status === "identical" || status === "ahead" || isProtectedFile(file)) continue;
|