diff --git a/.gitignore b/.gitignore index 5935635a41..e29abea66b 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,10 @@ codex.config.json perf-baseline-results.json .claude plans/ -dev/ \ No newline at end of file +dev/ + +tests/layout-snapshots/candidate/ +tests/layout-snapshots/reference/ +test-corpus/ +.pnpm-store + diff --git a/devtools/visual-testing/README.md b/devtools/visual-testing/README.md index 03494f8122..a6629a2571 100644 --- a/devtools/visual-testing/README.md +++ b/devtools/visual-testing/README.md @@ -72,8 +72,8 @@ Examples: - `pnpm compare` compare visual + interaction snapshots. - `pnpm compare:visual` compare visual snapshots only. - `pnpm compare:interactions` compare interaction snapshots only. -- `pnpm upload --folder ` upload a single docx into the corpus and update `registry.json`. -- `pnpm get-corpus [dest] --filter ` download corpus docs into a local folder (default: `./test-docs`). +- `pnpm upload --folder ` upload a single docx via the shared repo corpus CLI and update `registry.json`. +- `pnpm get-corpus [dest] --filter ` download corpus docs via the shared repo corpus CLI (default: `./test-docs`). - `pnpm get-docx ` download a single docx into a temp folder (prints the local path). - `pnpm filters` list filterable folders for `--filter`. - `pnpm clear:all` remove all baselines, screenshots, and results. @@ -96,6 +96,7 @@ Notes: - `--filter ` match by path/story prefix (e.g. `layout`, `sd-1401`). - `--match ` match by substring anywhere in path/story. - `--exclude ` skip by path/story prefix. +- `--doc ` target specific corpus docs on visual commands (repeatable), e.g. `comments-tcs/basic-comments.docx`. - Repeat `--filter`, `--match`, or `--exclude` to combine multiple values. - `--force` regenerate baselines even if they already exist. - `--skip-existing` skip docs/stories that already have outputs. diff --git a/devtools/visual-testing/pnpm-lock.yaml b/devtools/visual-testing/pnpm-lock.yaml index 9c960fa4f6..769d59e81b 100644 --- a/devtools/visual-testing/pnpm-lock.yaml +++ b/devtools/visual-testing/pnpm-lock.yaml @@ -2072,8 +2072,8 @@ packages: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} superdoc@file:../../packages/superdoc/superdoc.tgz: - resolution: {integrity: sha512-jBc73CsYGAxZJwWjMnPPLh4HhvRFv1uwKA/6KGgGOqV3RVaC0xFzRtMJsCt4ffj1YQgC6SSdxKyH3wKcLwsiUw==, tarball: file:../../packages/superdoc/superdoc.tgz} - version: 1.11.0 + resolution: {integrity: sha512-dSL1gyzlVZO4sMehWdbFeXBhUe4JkLvXZgrSrhqjp6DLaVPtad4KW1kIHryP+tTEllqGtDjerlMOiDSQaAwz8w==, tarball: file:../../packages/superdoc/superdoc.tgz} + version: 1.13.1 peerDependencies: '@hocuspocus/provider': ^2.13.6 pdfjs-dist: '>=4.3.136 <=4.6.82' diff --git a/devtools/visual-testing/scripts/baseline-all.ts b/devtools/visual-testing/scripts/baseline-all.ts index f92d15b15d..145d94b81a 100644 --- a/devtools/visual-testing/scripts/baseline-all.ts +++ b/devtools/visual-testing/scripts/baseline-all.ts @@ -16,6 +16,7 @@ function extractVersion(args: string[]): string | undefined { '--filter', '--match', '--exclude', + '--doc', '--parallel', '--output', '--browser', @@ -34,8 +35,22 @@ function extractVersion(args: string[]): string | undefined { return undefined; } +function stripDocSelectors(args: string[]): string[] { + const output: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--doc') { + i += 1; + continue; + } + output.push(arg); + } + return output; +} + async function main(): Promise { const passThrough = process.argv.slice(2); + const interactionPassThrough = stripDocSelectors(passThrough); const version = extractVersion(passThrough); if (version) { @@ -49,7 +64,7 @@ async function main(): Promise { } await runCommand(['exec', 'tsx', 'scripts/baseline-visual.ts', ...passThrough]); - await runCommand(['exec', 'tsx', 'scripts/baseline-interactions.ts', ...passThrough]); + await runCommand(['exec', 'tsx', 'scripts/baseline-interactions.ts', ...interactionPassThrough]); } const isMainModule = import.meta.url === `file://${process.argv[1]}`; diff --git a/devtools/visual-testing/scripts/baseline-visual.ts b/devtools/visual-testing/scripts/baseline-visual.ts index e532242305..ec2cb22669 100644 --- a/devtools/visual-testing/scripts/baseline-visual.ts +++ b/devtools/visual-testing/scripts/baseline-visual.ts @@ -60,6 +60,7 @@ function extractVersion(args: string[]): string | undefined { '--filter', '--match', '--exclude', + '--doc', '--parallel', '--output', '--browser', diff --git a/devtools/visual-testing/scripts/compare-all.ts b/devtools/visual-testing/scripts/compare-all.ts index 078dec4791..3da01e89fd 100644 --- a/devtools/visual-testing/scripts/compare-all.ts +++ b/devtools/visual-testing/scripts/compare-all.ts @@ -217,7 +217,7 @@ async function runGenerateVisualResultsForDocs( const args = ['exec', 'tsx', 'scripts/generate-refs.ts', '--output', outputFolder]; args.push('--append'); for (const doc of docs) { - args.push('--filter', doc); + args.push('--doc', doc); } for (const exclude of excludes) { args.push('--exclude', exclude); diff --git a/devtools/visual-testing/scripts/compare.test.ts b/devtools/visual-testing/scripts/compare.test.ts index 9a79c142a0..27df9bf406 100644 --- a/devtools/visual-testing/scripts/compare.test.ts +++ b/devtools/visual-testing/scripts/compare.test.ts @@ -4,6 +4,7 @@ import { findLatestResultsFolder, findPngFiles, matchesFilterWithBrowserPrefix, + docPathToScreenshotFilter, } from './compare.js'; describe('extractVersionFromFolder', () => { @@ -112,3 +113,29 @@ describe('matchesFilterWithBrowserPrefix', () => { expect(result).toBe(true); }); }); + +describe('docPathToScreenshotFilter', () => { + it('converts a nested .docx path to its screenshot filter', () => { + expect(docPathToScreenshotFilter('comments-tcs/basic-comments.docx')).toBe('comments-tcs/basic-comments'); + }); + + it('converts a flat .docx path to a bare name', () => { + expect(docPathToScreenshotFilter('simple.docx')).toBe('simple'); + }); + + it('sanitizes special characters in the filename', () => { + expect(docPathToScreenshotFilter('folder/My Doc (v2).docx')).toBe('folder/my-doc-v2'); + }); + + it('strips the test-docs/ prefix', () => { + expect(docPathToScreenshotFilter('test-docs/basic/simple.docx')).toBe('basic/simple'); + }); + + it('strips leading ./ and backslashes', () => { + expect(docPathToScreenshotFilter('./basic\\nested.docx')).toBe('basic/nested'); + }); + + it('handles a path with no extension', () => { + expect(docPathToScreenshotFilter('folder/readme')).toBe('folder/readme'); + }); +}); diff --git a/devtools/visual-testing/scripts/compare.ts b/devtools/visual-testing/scripts/compare.ts index 5e73d74fbe..67573460b1 100644 --- a/devtools/visual-testing/scripts/compare.ts +++ b/devtools/visual-testing/scripts/compare.ts @@ -16,6 +16,7 @@ * pnpm compare --filter sdt # Only generate/compare files in sdt/ folder * pnpm compare --exclude samples # Skip files in samples/ folder * pnpm compare --match sd-1401 # Match substring anywhere in path + * pnpm compare --doc comments-tcs/basic-comments.docx # Compare a specific corpus doc * pnpm compare --folder # Compare an existing results folder (skip generation) * pnpm compare --results-root # Read comparison results from this root folder * pnpm compare --report-all # Include passing pages in the HTML report @@ -27,8 +28,8 @@ import path from 'node:path'; import os from 'node:os'; import { createHash } from 'node:crypto'; import { spawn, spawnSync } from 'node:child_process'; -import { PNG } from 'pngjs'; -import pixelmatch from 'pixelmatch'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; import { generateResultsFolderName, getSuperdocVersion, sanitizeFilename } from './generate-refs.js'; import { buildDocRelativePath, createCorpusProvider, type CorpusProvider } from './corpus-provider.js'; import { writeHtmlReport } from './report.js'; @@ -56,12 +57,27 @@ import { } from './storage-flags.js'; import { HARNESS_PORT, HARNESS_URL, isPortOpen, ensureHarnessRunning, stopHarness } from './harness-utils.js'; import { ensureLocalTarballInstalled } from './workspace-utils.js'; +import { normalizeDocPath } from './utils.js'; + +const require = createRequire(import.meta.url); +const { PNG } = require('pngjs') as typeof import('pngjs'); +const pixelmatch = require('pixelmatch') as typeof import('pixelmatch').default; // Configuration const SCREENSHOTS_DIR = 'screenshots'; const BASELINES_DIR = 'baselines'; const RESULTS_DIR = 'results'; const REPORT_FILE = 'report.json'; +const WORD_OPEN_STAGING_ENV = 'SUPERDOC_WORD_OPEN_DIR'; +const WORD_OPEN_STAGING_DEFAULT = path.join( + os.homedir(), + 'Library', + 'Containers', + 'com.microsoft.Word', + 'Data', + 'Documents', + 'superdoc-report-open', +); export interface CompareOptions { /** Threshold for pixel difference (0-100, default: 0.05) */ @@ -111,6 +127,8 @@ export interface ImageCompareResult { reason?: CompareFailureReason; /** Word comparison assets (if generated) */ word?: WordImageSet; + /** Source document metadata (if available) */ + sourceDoc?: SourceDocMetadata; /** Interaction story metadata (if available) */ interaction?: InteractionMetadata; } @@ -128,6 +146,16 @@ export interface InteractionMetadata { milestoneDescription?: string; } +/** Metadata linking a comparison result back to its source .docx file. */ +export interface SourceDocMetadata { + /** Corpus-relative path of the source document (e.g. "tables/basic.docx") */ + relativePath: string; + /** Absolute local path to the (possibly staged) copy of the document */ + localPath: string; + /** ms-word: protocol deep-link URL (macOS only) */ + wordUrl?: string; +} + type DocumentInfo = { relativePath: string; absolutePath?: string; @@ -225,6 +253,7 @@ async function runGenerate(options: { filters: string[]; matches: string[]; excludes: string[]; + docs: string[]; append?: boolean; browser?: BrowserName; scaleFactor?: number; @@ -241,6 +270,9 @@ async function runGenerate(options: { for (const exclude of options.excludes) { args.push('--exclude', exclude); } + for (const doc of options.docs) { + args.push('--doc', doc); + } if (options.append) { args.push('--append'); } @@ -278,6 +310,7 @@ async function runBaseline(options: { filters: string[]; matches: string[]; excludes: string[]; + docs: string[]; browserArg?: string; scaleFactor?: number; storageArgs?: string[]; @@ -295,6 +328,9 @@ async function runBaseline(options: { for (const exclude of options.excludes) { args.push('--exclude', exclude); } + for (const doc of options.docs) { + args.push('--doc', doc); + } if (options.scaleFactor && options.scaleFactor !== 1) { args.push('--scale-factor', String(options.scaleFactor)); } @@ -448,6 +484,14 @@ function normalizePath(pathValue: string): string { return pathValue.replace(/\\/g, '/'); } +export function docPathToScreenshotFilter(pathValue: string): string { + const normalized = normalizeDocPath(pathValue); + const parsed = path.posix.parse(normalized); + const baseName = sanitizeFilename(parsed.name || parsed.base); + const directory = normalizePath(parsed.dir); + return directory && directory !== '.' ? normalizePath(path.posix.join(directory, baseName)) : baseName; +} + /** * Normalize a prefix string, ensuring it ends with a trailing slash. * @@ -778,6 +822,31 @@ function parseDocKeyAndPage( return { docKey, pageIndex, pageToken }; } +function toWordDeepLink(localPath: string): string | undefined { + if (process.platform !== 'darwin') return undefined; + return `ms-word:ofe|u|${pathToFileURL(localPath).href}`; +} + +function resolveWordOpenStagingDir(): string | undefined { + if (process.platform !== 'darwin') return undefined; + const custom = (process.env[WORD_OPEN_STAGING_ENV] ?? '').trim(); + if (custom) { + return path.resolve(custom); + } + return WORD_OPEN_STAGING_DEFAULT; +} + +function stageDocForWordOpen(localPath: string, identity: string): string { + const stagingDir = resolveWordOpenStagingDir(); + if (!stagingDir) return localPath; + + const digest = createHash('sha1').update(identity).digest('hex').slice(0, 20); + const stagedPath = path.join(stagingDir, `${digest}.docx`); + fs.mkdirSync(path.dirname(stagedPath), { recursive: true }); + fs.copyFileSync(localPath, stagedPath); + return stagedPath; +} + async function buildDocumentKeyMap(provider: CorpusProvider): Promise> { const docs = await listCorpusDocuments(provider); const map = new Map(); @@ -832,6 +901,92 @@ export async function findMissingDocuments( return { missingDocs, unknownKeys }; } +async function augmentReportWithSourceDocs( + report: ComparisonReport, + options: { + resultsPrefix?: string; + providerOptions?: { mode: StorageMode; docsDir?: string }; + }, +): Promise { + const diffResults = report.results.filter((item) => !item.passed); + if (diffResults.length === 0) { + return report; + } + + let provider: CorpusProvider | null = null; + try { + provider = await createCorpusProvider(options.providerOptions); + const docInfoMap = await buildDocumentInfoMap(provider); + const sourceDocByKey = new Map(); + + for (const item of diffResults) { + const parsed = parseDocKeyAndPage(item.relativePath, report.resultsFolder, options.resultsPrefix); + if (!parsed) continue; + const { docKey } = parsed; + if (sourceDocByKey.has(docKey)) continue; + + const docInfo = docInfoMap.get(docKey); + if (!docInfo) { + sourceDocByKey.set(docKey, null); + continue; + } + + try { + const localPath = await provider.fetchDoc(docInfo.doc_id, docInfo.doc_rev); + let openPath = localPath; + try { + openPath = stageDocForWordOpen(localPath, `${docInfo.relativePath}:${docInfo.doc_rev}`); + } catch (error) { + console.warn( + colors.warning( + `Unable to stage doc for Word open (${docInfo.relativePath}): ${ + error instanceof Error ? error.message : String(error) + }`, + ), + ); + } + + sourceDocByKey.set(docKey, { + relativePath: docInfo.relativePath, + localPath: openPath, + wordUrl: toWordDeepLink(openPath), + }); + } catch (error) { + sourceDocByKey.set(docKey, null); + console.warn( + colors.warning( + `Skipping source doc metadata for ${docKey}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + } + + if (sourceDocByKey.size === 0) { + return report; + } + + for (const item of report.results) { + const parsed = parseDocKeyAndPage(item.relativePath, report.resultsFolder, options.resultsPrefix); + if (!parsed) continue; + const sourceDoc = sourceDocByKey.get(parsed.docKey); + if (sourceDoc) { + item.sourceDoc = sourceDoc; + } + } + + return report; + } catch (error) { + console.warn( + colors.warning( + `Skipping source doc metadata enrichment: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + return report; + } finally { + await provider?.close?.(); + } +} + async function fillMissingDocs( resultsFolderName: string, baselineFolder: string, @@ -862,9 +1017,10 @@ async function fillMissingDocs( console.log(colors.muted(`Filling ${missingDocs.length} missing doc(s)...`)); await runGenerate({ outputFolder: resultsFolderName, - filters: missingDocs, + filters: [], matches: [], excludes, + docs: missingDocs, append: true, browser, scaleFactor, @@ -1739,6 +1895,7 @@ function parseArgs(): { filters: string[]; matches: string[]; excludes: string[]; + docs: string[]; baselineRoot?: string; resultsRoot?: string; resultsPrefix?: string; @@ -1762,6 +1919,7 @@ function parseArgs(): { const filters: string[] = []; const matches: string[] = []; const excludes: string[] = []; + const docs: string[] = []; let baselineRoot: string | undefined; let resultsRoot: string | undefined; let resultsPrefix: string | undefined; @@ -1804,6 +1962,12 @@ function parseArgs(): { excludes.push(rawExclude); } i++; + } else if (args[i] === '--doc' && args[i + 1]) { + const rawDoc = args[i + 1].trim(); + if (rawDoc) { + docs.push(rawDoc); + } + i++; } else if (args[i] === '--baseline-root' && args[i + 1]) { baselineRoot = args[i + 1]; i++; @@ -1860,6 +2024,7 @@ function parseArgs(): { filters, matches, excludes, + docs, baselineRoot, resultsRoot, resultsPrefix, @@ -1889,6 +2054,7 @@ async function main(): Promise { filters, matches, excludes, + docs, baselineRoot, resultsRoot, resultsPrefix, @@ -1904,6 +2070,15 @@ async function main(): Promise { mode, docsDir, } = parseArgs(); + const normalizedDocs = Array.from(new Set(docs.map((value) => normalizeDocPath(value)).filter(Boolean))); + const docFilters = normalizedDocs.map((docPath) => docPathToScreenshotFilter(docPath)); + const effectiveFilters = docFilters.length > 0 ? docFilters : filters; + const generationFilters = normalizedDocs.length > 0 ? [] : filters; + + if (docFilters.length > 0 && filters.length > 0) { + console.warn(colors.warning('Using --doc selectors and ignoring --filter values for comparison scope.')); + } + const storageArgs = buildStorageArgs(mode, docsDir); const normalizedResultsPrefix = normalizePrefix(resultsPrefix); const normalizedReportTrim = normalizePrefix(reportTrim); @@ -1911,7 +2086,7 @@ async function main(): Promise { reportMode === 'interactions' || (normalizedResultsPrefix ? normalizedResultsPrefix.startsWith('interactions/') : false) || (normalizedReportTrim ? normalizedReportTrim.startsWith('interactions/') : false) || - filters.some((value) => value.startsWith('interactions/')); + effectiveFilters.some((value) => value.startsWith('interactions/')); const baselinePrefix = isInteractionMode ? 'baselines-interactions' : BASELINES_DIR; const baselineDir = resolveBaselineRoot(baselinePrefix, mode, baselineRoot); const resolvedResultsRoot = resultsRoot ? resolvePathInput(resultsRoot) : undefined; @@ -1948,8 +2123,14 @@ async function main(): Promise { const ensureBaseline = async (version: string, versionSpec?: string, force: boolean = false): Promise => { if (mode === 'local') { const baselinePath = path.join(baselineDir, version); - if (!fs.existsSync(baselinePath)) { - console.log(colors.info(`📸 Baseline ${version} not found locally. Generating...`)); + const baselineExists = fs.existsSync(baselinePath); + const shouldEnsureSelectedDocs = baselineExists && normalizedDocs.length > 0; + if (!baselineExists || shouldEnsureSelectedDocs) { + if (!baselineExists) { + console.log(colors.info(`📸 Baseline ${version} not found locally. Generating...`)); + } else { + console.log(colors.info(`📸 Ensuring baseline coverage for ${normalizedDocs.length} selected doc(s)...`)); + } const script = baselinePrefix === 'baselines-interactions' ? 'scripts/baseline-interactions.ts' @@ -1964,9 +2145,10 @@ async function main(): Promise { await runBaseline({ script, versionSpec, - filters, + filters: generationFilters, matches, excludes, + docs: script === 'scripts/baseline-visual.ts' ? normalizedDocs : [], browserArg, scaleFactor, storageArgs, @@ -1983,7 +2165,7 @@ async function main(): Promise { return; } - const hasFilters = filters.length > 0 || matches.length > 0 || excludes.length > 0; + const hasFilters = effectiveFilters.length > 0 || matches.length > 0 || excludes.length > 0; const browserFilters = browserArg ? browsers : undefined; if (refreshBaselines) { if (hasFilters || browserFilters) { @@ -1991,7 +2173,7 @@ async function main(): Promise { prefix: baselinePrefix, version, localRoot: baselineDir, - filters, + filters: effectiveFilters, matches, excludes, browsers: browserFilters, @@ -2028,8 +2210,10 @@ async function main(): Promise { process.exit(1); } + const baselineSpecForEnsure = baselineVersion || normalizedDocs.length > 0 ? baselineSelection?.spec : undefined; + if (!targetVersion) { - await ensureBaseline(baselineToUse, baselineVersion ? baselineSelection?.spec : undefined); + await ensureBaseline(baselineToUse, baselineSpecForEnsure); } if (targetVersion) { @@ -2047,10 +2231,19 @@ async function main(): Promise { await runVersionSwitch(targetSpec); console.log(colors.muted(`Generating: ${targetLabel}`)); for (const browser of browsers) { - await runGenerate({ outputFolder: targetLabel, filters, matches, excludes, browser, scaleFactor, storageArgs }); + await runGenerate({ + outputFolder: targetLabel, + filters: generationFilters, + matches, + excludes, + docs: normalizedDocs, + browser, + scaleFactor, + storageArgs, + }); } - await ensureBaseline(baselineToUse, baselineVersion ? baselineSelection?.spec : undefined); + await ensureBaseline(baselineToUse, baselineSpecForEnsure); resultsFolderName = targetLabel; @@ -2059,7 +2252,7 @@ async function main(): Promise { await fillMissingDocs( resultsFolderName, baselineFolder, - filters, + effectiveFilters, matches, excludes, browser, @@ -2083,9 +2276,10 @@ async function main(): Promise { for (const browser of browsers) { await runGenerate({ outputFolder: resultsFolderName, - filters, + filters: generationFilters, matches, excludes, + docs: normalizedDocs, browser, scaleFactor, storageArgs, @@ -2097,7 +2291,7 @@ async function main(): Promise { await fillMissingDocs( resultsFolderName, baselineFolder, - filters, + effectiveFilters, matches, excludes, browser, @@ -2124,7 +2318,7 @@ async function main(): Promise { const resolvedMode = reportMode ?? - (reportTrim || filters.some((value) => value.startsWith('interactions/')) ? 'interactions' : 'visual'); + (reportTrim || effectiveFilters.some((value) => value.startsWith('interactions/')) ? 'interactions' : 'visual'); const resolvedTrim = reportTrim ?? (resolvedMode === 'interactions' ? 'interactions/' : undefined); const ignorePrefixes = resolvedMode === 'visual' ? ['interactions/'] : undefined; @@ -2134,7 +2328,10 @@ async function main(): Promise { for (const browser of browsers) { // Build compact config line const configParts = [`Baseline: ${baselineToUse}`, `Browser: ${browser}`]; - if (filters.length > 0) configParts.push(`Filter: "${filters.join(', ')}"`); + if (docFilters.length > 0) configParts.push(`Docs: ${docFilters.length}`); + if (docFilters.length === 0 && effectiveFilters.length > 0) { + configParts.push(`Filter: "${effectiveFilters.join(', ')}"`); + } if (matches.length > 0) configParts.push(`Match: "${matches.join(', ')}"`); if (excludes.length > 0) configParts.push(`Exclude: "${excludes.join(', ')}"`); if (threshold > 0) configParts.push(`Threshold: ${threshold}%`); @@ -2151,7 +2348,7 @@ async function main(): Promise { resultsPrefix, browser, outputFolderName, - filters, + filters: effectiveFilters, matches, excludes, ignorePrefixes, @@ -2188,7 +2385,7 @@ async function main(): Promise { resultsPrefix, browser, outputFolderName, - filters, + filters: effectiveFilters, matches, excludes, ignorePrefixes, @@ -2209,15 +2406,22 @@ async function main(): Promise { } } - if (resolvedMode === 'visual' && includeWord) { - const wordResultsPrefix = browser ? `${normalizePrefix(resultsPrefix) ?? ''}${browser}/` : resultsPrefix; - report = await augmentReportWithWord(report, { - resultsFolderName: resultsFolderName!, - resultsPrefix: wordResultsPrefix, - targetVersion: resolvedTargetVersion, + if (resolvedMode === 'visual') { + const visualResultsPrefix = browser ? `${normalizePrefix(resultsPrefix) ?? ''}${browser}/` : resultsPrefix; + report = await augmentReportWithSourceDocs(report, { + resultsPrefix: visualResultsPrefix, providerOptions: { mode, docsDir }, }); + if (includeWord) { + report = await augmentReportWithWord(report, { + resultsFolderName: resultsFolderName!, + resultsPrefix: visualResultsPrefix, + targetVersion: resolvedTargetVersion, + providerOptions: { mode, docsDir }, + }); + } + const outputFolder = path.join(RESULTS_DIR, outputFolderName); fs.writeFileSync(path.join(outputFolder, REPORT_FILE), JSON.stringify(report, null, 2)); writeHtmlReport(report, outputFolder, { diff --git a/devtools/visual-testing/scripts/generate-refs.ts b/devtools/visual-testing/scripts/generate-refs.ts index d9bb495220..6a135c8b90 100644 --- a/devtools/visual-testing/scripts/generate-refs.ts +++ b/devtools/visual-testing/scripts/generate-refs.ts @@ -13,6 +13,7 @@ * pnpm generate --filter basic --filter layout * pnpm generate --exclude samples # Skip documents in samples/ folder * pnpm generate --match sd-1401 # Match substring anywhere in path + * pnpm generate --doc comments-tcs/basic-comments.docx # Only selected doc(s) * pnpm generate --output my-run # Write results to screenshots/my-run * pnpm generate --append # Keep existing output folder when using --output * pnpm generate --skip-existing # Skip docs that already have screenshots @@ -45,6 +46,7 @@ import { isPathLikeVersion, normalizeVersionLabel, versionLabelFromPath } from ' import { getBrowserType, resolveBrowserNames, type BrowserName } from './browser-utils.js'; import { ensureHarnessRunning, stopHarness, HARNESS_URL } from './harness-utils.js'; import { getBaselineOutputRoot, parseStorageFlags, resolveDocsDir, type StorageMode } from './storage-flags.js'; +import { normalizeDocPath } from './utils.js'; // Configuration const SCREENSHOTS_DIR = 'screenshots'; @@ -181,6 +183,10 @@ interface DocumentInfo { needsFetch?: boolean; } +function normalizeDocSelector(value: string): string { + return normalizeDocPath(value).toLowerCase(); +} + /** * Find documents from the corpus registry. */ @@ -188,12 +194,19 @@ export async function findDocumentsFromCorpus( provider: CorpusProvider, outputDir: string, filters: CorpusFilters, + docSelectors: string[] = [], ): Promise { const docs = await provider.listDocs(filters); + const normalizedDocSelectors = Array.from(new Set(docSelectors.map((value) => normalizeDocSelector(value)))); + const hasDocSelectors = normalizedDocSelectors.length > 0; + const docSelectorSet = new Set(normalizedDocSelectors); const documents: DocumentInfo[] = []; for (const doc of docs) { const relativePath = buildDocRelativePath(doc); + if (hasDocSelectors && !docSelectorSet.has(normalizeDocSelector(relativePath))) { + continue; + } const ext = path.extname(doc.filename).toLowerCase(); if (!VALID_EXTENSIONS.has(ext)) { continue; @@ -434,6 +447,7 @@ function parseArgs(): { filters: string[]; matches: string[]; excludes: string[]; + docs: string[]; output?: string; skipExisting: boolean; failOnError: boolean; @@ -460,6 +474,7 @@ function parseArgs(): { const filters: string[] = []; const matches: string[] = []; const excludes: string[] = []; + const docs: string[] = []; let output: string | undefined; let browserArg: string | undefined; let scaleFactor = 1.5; @@ -487,6 +502,12 @@ function parseArgs(): { excludes.push(rawExclude); } i++; + } else if (arg === '--doc' && args[i + 1]) { + const rawDoc = args[i + 1].trim(); + if (rawDoc) { + docs.push(rawDoc); + } + i++; } else if (arg === '--output' && args[i + 1]) { output = args[i + 1]; i++; @@ -518,6 +539,7 @@ function parseArgs(): { filters, matches, excludes, + docs, output, skipExisting, failOnError, @@ -640,6 +662,7 @@ async function runForBrowser(browser: BrowserName, options: ParsedArgs): Promise filters, matches, excludes, + docs, output, skipExisting, failOnError, @@ -690,7 +713,7 @@ async function runForBrowser(browser: BrowserName, options: ParsedArgs): Promise try { console.log(colors.info('🔍 Finding documents...')); - const documents = await findDocumentsFromCorpus(provider, outputDir, { filters, matches, excludes }); + const documents = await findDocumentsFromCorpus(provider, outputDir, { filters, matches, excludes }, docs); if (filters.length > 0) { console.log(colors.info(`🔎 Filter: "${filters.join(', ')}"`)); } @@ -700,6 +723,9 @@ async function runForBrowser(browser: BrowserName, options: ParsedArgs): Promise if (excludes.length > 0) { console.log(colors.info(`🔎 Exclude: "${excludes.join(', ')}"`)); } + if (docs.length > 0) { + console.log(colors.info(`🔎 Docs: ${docs.length} explicitly selected`)); + } if (documents.length === 0) { console.log(colors.warning('No documents found in corpus.')); diff --git a/devtools/visual-testing/scripts/get-corpus.ts b/devtools/visual-testing/scripts/get-corpus.ts index 219a225c2a..ef810e11ef 100644 --- a/devtools/visual-testing/scripts/get-corpus.ts +++ b/devtools/visual-testing/scripts/get-corpus.ts @@ -1,110 +1,79 @@ #!/usr/bin/env tsx -import fs from 'node:fs'; import path from 'node:path'; -import { colors } from './terminal.js'; -import { buildDocRelativePath, createCorpusProvider, type CorpusFilters } from './corpus-provider.js'; - -function printHelp(): void { - console.log( - `\nUsage: pnpm get-corpus [dest] [--filter ] [--match ] [--exclude ] [--dry-run]\n\nDefaults:\n dest = ./test-docs\n\nOptions:\n --filter Prefix filter (repeatable)\n --match Substring match filter (repeatable)\n --exclude Exclude filter (repeatable)\n --dry-run Print actions without downloading\n`, - ); -} - -function parseArgs(): { - dest: string; - filters: string[]; - matches: string[]; - excludes: string[]; - dryRun: boolean; -} { - const args = process.argv.slice(2); - let dest: string | undefined; - const filters: string[] = []; - const matches: string[] = []; - const excludes: string[] = []; - let dryRun = false; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (arg === '--filter' && args[i + 1]) { - const value = args[i + 1].trim(); - if (value) filters.push(value); - i += 1; - } else if (arg === '--match' && args[i + 1]) { - const value = args[i + 1].trim(); - if (value) matches.push(value); - i += 1; - } else if (arg === '--exclude' && args[i + 1]) { - const value = args[i + 1].trim(); - if (value) excludes.push(value); - i += 1; - } else if (arg === '--dry-run') { - dryRun = true; - } else if (arg === '--help' || arg === '-h') { - printHelp(); - process.exit(0); - } else if (!arg.startsWith('--') && !dest) { - dest = arg; +import { spawn } from 'node:child_process'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '../../..'); + +function parseArgs(rawArgs: string[]): { commandArgs: string[] } { + const args = rawArgs.filter((arg) => arg !== '--'); + const hasExplicitDest = args.includes('--dest'); + const forwarded: string[] = []; + let positionalDest: string | null = null; + let expectsValueForFlag: string | null = null; + let seenFlag = false; + + for (const arg of args) { + if (expectsValueForFlag) { + forwarded.push(arg); + expectsValueForFlag = null; + continue; } - } - return { dest: dest ?? 'test-docs', filters, matches, excludes, dryRun }; -} - -function ensureDestination(destRoot: string): void { - if (fs.existsSync(destRoot)) { - if (!fs.statSync(destRoot).isDirectory()) { - throw new Error(`Destination is not a directory: ${destRoot}`); + if (arg.startsWith('-')) { + seenFlag = true; } - return; - } - fs.mkdirSync(destRoot, { recursive: true }); -} -async function main(): Promise { - const { dest, filters, matches, excludes, dryRun } = parseArgs(); - const destRoot = path.resolve(dest); - ensureDestination(destRoot); + if (arg === '--dest' || arg === '--filter' || arg === '--match' || arg === '--exclude') { + forwarded.push(arg); + expectsValueForFlag = arg; + continue; + } - const provider = await createCorpusProvider({ mode: 'cloud' }); - const filterSpec: CorpusFilters = { filters, matches, excludes }; - const docs = await provider.listDocs(filterSpec); + if (!arg.startsWith('-') && !hasExplicitDest && positionalDest === null && !seenFlag) { + positionalDest = arg; + continue; + } - if (docs.length === 0) { - console.log(colors.warning('No corpus documents matched the filters.')); - return; + forwarded.push(arg); } - console.log(colors.info(`Downloading ${docs.length} document(s) to ${destRoot}...`)); - - let downloaded = 0; - for (const doc of docs) { - const relativePath = buildDocRelativePath(doc); - const targetPath = path.join(destRoot, relativePath); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const defaultDest = positionalDest ?? 'test-docs'; + const resolvedDest = hasExplicitDest ? null : path.resolve(process.cwd(), defaultDest); + const commandArgs = ['run', 'corpus:pull', '--']; - console.log(colors.muted(`- ${relativePath}`)); + if (resolvedDest) { + commandArgs.push('--dest', resolvedDest); + } - if (dryRun) continue; + commandArgs.push(...forwarded); + return { commandArgs }; +} - const sourcePath = await provider.fetchDoc(doc.doc_id, doc.doc_rev); - fs.copyFileSync(sourcePath, targetPath); - downloaded += 1; - } +async function main(): Promise { + const { commandArgs } = parseArgs(process.argv.slice(2)); + const child = spawn('pnpm', commandArgs, { + cwd: REPO_ROOT, + env: process.env, + stdio: 'inherit', + }); - if (dryRun) { - console.log(colors.success(`✅ Dry run complete (${docs.length} match(es)).`)); - return; - } + const exitCode = await new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', (err) => { + console.error(`Failed to spawn corpus:pull: ${err.message}`); + resolve(1); + }); + }); - console.log(colors.success(`✅ Downloaded ${downloaded} document(s).`)); + process.exit(Number(exitCode)); } const isMainModule = import.meta.url === `file://${process.argv[1]}`; if (isMainModule) { main().catch((error) => { - console.error(colors.error(`Fatal error: ${error instanceof Error ? error.message : String(error)}`)); + const message = error instanceof Error ? error.message : String(error); + console.error(`[get-corpus] Fatal: ${message}`); process.exitCode = 1; }); } diff --git a/devtools/visual-testing/scripts/report.ts b/devtools/visual-testing/scripts/report.ts index ef19d03768..fe5738ddae 100644 --- a/devtools/visual-testing/scripts/report.ts +++ b/devtools/visual-testing/scripts/report.ts @@ -245,6 +245,11 @@ export function writeHtmlReport( border: 1px solid rgba(15, 25, 45, 0.1); } + button:disabled { + cursor: not-allowed; + opacity: 0.6; + } + main { padding: 24px 28px 60px; } @@ -318,8 +323,11 @@ export function writeHtmlReport( color: var(--ink-700); } - summary .approve-btn { + .summary-actions { margin-left: auto; + display: flex; + align-items: center; + gap: 8px; } .group-body { @@ -458,6 +466,24 @@ export function writeHtmlReport( border-color: rgba(82, 212, 166, 0.6); } + .word-btn { + border: 1px solid rgba(15, 25, 45, 0.18); + background: rgba(15, 25, 45, 0.06); + color: var(--ink-700); + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .word-btn:hover:not(:disabled) { + background: rgba(15, 25, 45, 0.12); + border-color: rgba(15, 25, 45, 0.28); + } + details.group.approved { display: none; } @@ -755,6 +781,7 @@ export function writeHtmlReport( diffPercent: item.diffPercent, hasDiff: Boolean(item.diffPath), word: item.word || null, + sourceDoc: item.sourceDoc || null, milestoneLabel, storyName, storyDescription, @@ -932,15 +959,42 @@ export function writeHtmlReport( count.textContent = items.length + (items.length === 1 ? ' diff' : ' diffs'); } + const sourceDoc = items.find((item) => item.sourceDoc)?.sourceDoc || null; + const actionWrap = document.createElement('div'); + actionWrap.className = 'summary-actions'; + + if (!isInteractions) { + const wordBtn = document.createElement('button'); + wordBtn.type = 'button'; + wordBtn.className = 'word-btn'; + wordBtn.textContent = 'Open in Word'; + + if (sourceDoc && sourceDoc.wordUrl) { + wordBtn.dataset.wordUrl = sourceDoc.wordUrl; + wordBtn.title = sourceDoc.relativePath + ? 'Open ' + sourceDoc.relativePath + ' in Word' + : 'Open in Word'; + } else { + wordBtn.disabled = true; + wordBtn.title = + sourceDoc && sourceDoc.localPath + ? 'Open in Word is available on macOS only.' + : 'Doc not available locally.'; + } + + actionWrap.appendChild(wordBtn); + } + const approveBtn = document.createElement('button'); approveBtn.type = 'button'; approveBtn.className = 'approve-btn'; approveBtn.textContent = 'Approve doc'; approveBtn.dataset.group = dir; + actionWrap.appendChild(approveBtn); summary.appendChild(titleWrap); summary.appendChild(count); - summary.appendChild(approveBtn); + summary.appendChild(actionWrap); details.appendChild(summary); const body = document.createElement('div'); @@ -1255,6 +1309,18 @@ export function writeHtmlReport( } groupsContainer.addEventListener('click', (event) => { + const openWordBtn = event.target.closest('.word-btn'); + if (openWordBtn) { + event.preventDefault(); + event.stopPropagation(); + if (openWordBtn.disabled) return; + const wordUrl = openWordBtn.dataset.wordUrl; + if (wordUrl) { + window.location.assign(wordUrl); + } + return; + } + const btn = event.target.closest('.approve-btn'); if (!btn) return; diff --git a/devtools/visual-testing/scripts/upload-corpus-doc.ts b/devtools/visual-testing/scripts/upload-corpus-doc.ts index f1f018a557..626bc15648 100644 --- a/devtools/visual-testing/scripts/upload-corpus-doc.ts +++ b/devtools/visual-testing/scripts/upload-corpus-doc.ts @@ -1,242 +1,36 @@ #!/usr/bin/env tsx -import fs from 'node:fs'; import path from 'node:path'; -import crypto from 'node:crypto'; -import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; -import { colors } from './terminal.js'; -import { buildDocRelativePath, type CorpusRegistry } from './corpus-provider.js'; +import { spawn } from 'node:child_process'; -const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - -function normalizeSegment(value: string): string { - return value - .trim() - .replace(/[^A-Za-z0-9._-]+/g, '-') - .replace(/-+/g, '-') - .replace(/^[-_.]+|[-_.]+$/g, '') - .toLowerCase(); -} - -function normalizePath(value: string): string { - return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, ''); -} - -function sha256Buffer(buffer: Buffer): string { - const hash = crypto.createHash('sha256'); - hash.update(buffer); - return `sha256:${hash.digest('hex')}`; -} - -function parseArgs(): { filePath: string; folder?: string; relativePath?: string; dryRun: boolean } { - const args = process.argv.slice(2); - let folder: string | undefined; - let relativePath: string | undefined; - let filePath: string | undefined; - let dryRun = false; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (arg === '--folder' && args[i + 1]) { - folder = args[i + 1]; - i += 1; - } else if (arg === '--path' && args[i + 1]) { - relativePath = args[i + 1]; - i += 1; - } else if (arg === '--dry-run') { - dryRun = true; - } else if (arg === '--help' || arg === '-h') { - printHelp(); - process.exit(0); - } else if (!arg.startsWith('--') && !filePath) { - filePath = arg; - } - } - - if (!filePath) { - printHelp(); - throw new Error('Missing file path.'); - } - - return { filePath, folder, relativePath, dryRun }; -} - -function printHelp(): void { - console.log( - `\nUsage: pnpm upload [--folder ] [--path ] \n\nOptions:\n --folder Upload under a folder in R2 (e.g., lists)\n --path Full relative object key in R2 (overrides --folder)\n --dry-run Print actions without uploading\n`, - ); -} - -async function createClient(): Promise<{ client: S3Client; bucketName: string }> { - const accountId = process.env.SD_TESTING_R2_ACCOUNT_ID ?? ''; - const bucketName = process.env.SD_TESTING_R2_BUCKET_NAME ?? ''; - const accessKeyId = process.env.SD_TESTING_R2_ACCESS_KEY_ID ?? ''; - const secretAccessKey = process.env.SD_TESTING_R2_SECRET_ACCESS_KEY ?? ''; - - if (!accountId) throw new Error('Missing SD_TESTING_R2_ACCOUNT_ID'); - if (!bucketName) throw new Error('Missing SD_TESTING_R2_BUCKET_NAME'); - if (!accessKeyId) throw new Error('Missing SD_TESTING_R2_ACCESS_KEY_ID'); - if (!secretAccessKey) throw new Error('Missing SD_TESTING_R2_SECRET_ACCESS_KEY'); - - const client = new S3Client({ - region: 'auto', - endpoint: `https://${accountId}.r2.cloudflarestorage.com`, - credentials: { accessKeyId, secretAccessKey }, - }); - - return { client, bucketName }; -} - -async function bodyToBuffer(body: unknown): Promise { - if (!body) throw new Error('Empty response body'); - if (Buffer.isBuffer(body)) return body; - if (body instanceof Uint8Array) return Buffer.from(body); - if (typeof body === 'string') return Buffer.from(body); - - const maybeTransform = body as { transformToByteArray?: () => Promise }; - if (typeof maybeTransform.transformToByteArray === 'function') { - const bytes = await maybeTransform.transformToByteArray(); - return Buffer.from(bytes); - } - - const asyncBody = body as AsyncIterable; - if (typeof asyncBody[Symbol.asyncIterator] === 'function') { - const chunks: Buffer[] = []; - for await (const chunk of asyncBody) { - chunks.push(Buffer.from(chunk)); - } - return Buffer.concat(chunks); - } - - throw new Error('Unsupported response body type'); -} - -async function fetchRegistry(client: S3Client, bucket: string): Promise { - try { - const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: 'registry.json' })); - const buffer = await bodyToBuffer(response.Body); - const parsed = JSON.parse(buffer.toString('utf8')) as CorpusRegistry; - if (!parsed || !Array.isArray(parsed.docs)) { - throw new Error('Invalid corpus registry format'); - } - return parsed; - } catch (error) { - const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; - if (status === 404 || (error as Error).name === 'NoSuchKey') { - throw new Error('registry.json not found. Run pnpm upload-corpus first.'); - } - throw error; - } -} +const REPO_ROOT = path.resolve(import.meta.dirname, '../../..'); async function main(): Promise { - const { filePath, folder, relativePath: relativeOverride, dryRun } = parseArgs(); - const resolvedPath = path.resolve(process.cwd(), filePath); - - if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) { - throw new Error(`File not found: ${resolvedPath}`); - } - - const ext = path.extname(resolvedPath).toLowerCase(); - if (ext !== '.docx') { - throw new Error('Only .docx files are supported.'); - } - - let relativePath: string; - if (relativeOverride) { - if (path.isAbsolute(relativeOverride)) { - throw new Error('--path must be a relative object key (e.g., lists/my.docx)'); - } - relativePath = normalizePath(relativeOverride); - } else { - const baseName = path.basename(resolvedPath); - if (folder) { - relativePath = normalizePath(path.posix.join(folder, baseName)); - } else { - relativePath = normalizePath(baseName); - } - } - - if (!relativePath.endsWith('.docx')) { - throw new Error('Target path must end with .docx.'); - } - - const buffer = fs.readFileSync(resolvedPath); - const doc_rev = sha256Buffer(buffer); - const relativeStem = relativePath.replace(/\.docx$/i, ''); - const doc_id = normalizeSegment(relativeStem.replace(/\//g, '-')); - const group = relativePath.includes('/') ? relativePath.split('/')[0] : undefined; - const filename = path.basename(relativePath); + const passthroughArgs = process.argv.slice(2).filter((arg) => arg !== '--'); + const commandArgs = ['run', 'corpus:push', '--', ...passthroughArgs]; - const { client, bucketName } = await createClient(); - const registry = await fetchRegistry(client, bucketName); - - const normalizedTarget = normalizePath(relativePath).toLowerCase(); - const docs = [...registry.docs]; - const indexById = docs.findIndex((doc) => doc.doc_id === doc_id); - const indexByPath = docs.findIndex((doc) => buildDocRelativePath(doc).toLowerCase() === normalizedTarget); - - const existing = indexById >= 0 ? docs[indexById] : indexByPath >= 0 ? docs[indexByPath] : undefined; - const tags = existing?.tags; - - const updatedDoc = { - doc_id, - doc_rev, - filename, - group, - relative_path: relativePath, - tags, - }; - - if (indexById >= 0) { - docs[indexById] = updatedDoc; - if (indexByPath >= 0 && indexByPath !== indexById) { - docs.splice(indexByPath, 1); - } - } else if (indexByPath >= 0) { - docs[indexByPath] = updatedDoc; - } else { - docs.push(updatedDoc); - } - - docs.sort((a, b) => - buildDocRelativePath(a).localeCompare(buildDocRelativePath(b), undefined, { sensitivity: 'base' }), - ); - - const nextRegistry: CorpusRegistry = { - updated_at: new Date().toISOString(), - docs, - }; - - console.log(colors.info(`Uploading ${relativePath} (doc_id=${doc_id})`)); - - if (!dryRun) { - await client.send( - new PutObjectCommand({ - Bucket: bucketName, - Key: relativePath, - Body: buffer, - ContentType: DOCX_CONTENT_TYPE, - }), - ); + const child = spawn('pnpm', commandArgs, { + cwd: REPO_ROOT, + env: process.env, + stdio: 'inherit', + }); - await client.send( - new PutObjectCommand({ - Bucket: bucketName, - Key: 'registry.json', - Body: Buffer.from(`${JSON.stringify(nextRegistry, null, 2)}\n`, 'utf8'), - ContentType: 'application/json', - }), - ); - } + const exitCode = await new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', (err) => { + console.error(`Failed to spawn corpus:push: ${err.message}`); + resolve(1); + }); + }); - console.log(colors.success('✅ Upload complete')); + process.exit(Number(exitCode)); } const isMainModule = import.meta.url === `file://${process.argv[1]}`; if (isMainModule) { main().catch((error) => { - console.error(colors.error(`Fatal error: ${error instanceof Error ? error.message : String(error)}`)); + const message = error instanceof Error ? error.message : String(error); + console.error(`[upload] Fatal: ${message}`); process.exitCode = 1; }); } diff --git a/devtools/visual-testing/scripts/utils.test.ts b/devtools/visual-testing/scripts/utils.test.ts new file mode 100644 index 0000000000..c121240404 --- /dev/null +++ b/devtools/visual-testing/scripts/utils.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeDocPath } from './utils.js'; + +describe('normalizeDocPath', () => { + it('returns a simple relative path unchanged', () => { + expect(normalizeDocPath('comments-tcs/basic-comments.docx')).toBe('comments-tcs/basic-comments.docx'); + }); + + it('strips the test-docs/ prefix (case-insensitive)', () => { + expect(normalizeDocPath('test-docs/basic/simple.docx')).toBe('basic/simple.docx'); + expect(normalizeDocPath('Test-Docs/basic/simple.docx')).toBe('basic/simple.docx'); + }); + + it('strips leading ./', () => { + expect(normalizeDocPath('./basic/simple.docx')).toBe('basic/simple.docx'); + }); + + it('strips leading slashes', () => { + expect(normalizeDocPath('/basic/simple.docx')).toBe('basic/simple.docx'); + expect(normalizeDocPath('///basic/simple.docx')).toBe('basic/simple.docx'); + }); + + it('converts backslashes to forward slashes', () => { + expect(normalizeDocPath('comments-tcs\\basic-comments.docx')).toBe('comments-tcs/basic-comments.docx'); + }); + + it('handles combined prefixes', () => { + expect(normalizeDocPath('./test-docs/nested/doc.docx')).toBe('nested/doc.docx'); + }); + + it('returns empty string for empty input', () => { + expect(normalizeDocPath('')).toBe(''); + }); + + it('handles a bare filename', () => { + expect(normalizeDocPath('simple.docx')).toBe('simple.docx'); + }); +}); diff --git a/devtools/visual-testing/scripts/utils.ts b/devtools/visual-testing/scripts/utils.ts index 4bd6a9f7c7..6b5b99be6f 100644 --- a/devtools/visual-testing/scripts/utils.ts +++ b/devtools/visual-testing/scripts/utils.ts @@ -23,6 +23,20 @@ export function normalizePath(value: string): string { return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, ''); } +/** + * Normalize a document path for comparison and filtering. + * Extends {@link normalizePath} by stripping the `test-docs/` corpus prefix + * and collapsing repeated leading slashes. + * + * @param value - Raw document path (may contain backslashes, leading ./, or test-docs/ prefix) + * @returns Cleaned path suitable for matching against corpus-relative paths + */ +export function normalizeDocPath(value: string): string { + return normalizePath(value) + .replace(/^\/+/, '') + .replace(/^test-docs\//i, ''); +} + /** * Creates a ring buffer for log output that keeps only the most recent content. * diff --git a/package.json b/package.json index 8d23241f7e..658063da11 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,11 @@ "release:local": "pnpm run build:superdoc && pnpm run type-check && pnpm --prefix packages/superdoc exec semantic-release --no-ci", "prepare": "if [ -z \"$CI\" ]; then lefthook install; fi", "test:layout": "node ./scripts/test-layout.mjs", + "layout:snapshots": "bun tests/layout-snapshots/export-layout-snapshots.mjs --jobs 4", + "layout:snapshots:npm": "bun tests/layout-snapshots/export-layout-snapshots-npm.mjs --jobs 4", + "layout:compare": "bun tests/layout-snapshots/compare-layout-snapshots.mjs --jobs 4", + "corpus:pull": "node scripts/corpus/pull.mjs", + "corpus:push": "node scripts/corpus/push.mjs", "watch": "pnpm --prefix packages/superdoc run watch:es", "check:all": "pnpm run format && pnpm run lint:fix && pnpm --prefix packages/super-editor run types:build && pnpm run test", "local:publish": "pnpm --prefix packages/superdoc version prerelease --preid=local && pnpm --prefix packages/superdoc publish --registry http://localhost:4873", diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 7cc276badc..b5b1cc8fa9 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -494,7 +494,6 @@ export class Editor extends EventEmitter { } if (!telemetryConfig?.enabled) { - console.debug('[super-editor] Telemetry: disabled'); return; } diff --git a/scripts/corpus/README.md b/scripts/corpus/README.md new file mode 100644 index 0000000000..36258874c2 --- /dev/null +++ b/scripts/corpus/README.md @@ -0,0 +1,33 @@ +# Shared Test Corpus + +Repo-level DOCX corpus tooling shared by `tests/visual` and `tests/layout-snapshots`. + +## Commands + +```bash +# Download/sync corpus locally (default: /test-corpus) +pnpm corpus:pull + +# Upload a doc and update registry.json in R2 +pnpm corpus:push -- --path rendering/sd-1234-example.docx /path/to/file.docx +``` + +## Auth + +Preferred local flow: + +```bash +npx wrangler login +``` + +CI / explicit credentials can use: + +- `SUPERDOC_CORPUS_R2_ACCOUNT_ID` +- `SUPERDOC_CORPUS_R2_BUCKET` +- `SUPERDOC_CORPUS_R2_ACCESS_KEY_ID` +- `SUPERDOC_CORPUS_R2_SECRET_ACCESS_KEY` + +Backward-compatible env names are also accepted: + +- `SD_TESTING_R2_*` +- `SD_VISUAL_TESTING_R2_*` diff --git a/scripts/corpus/pull.mjs b/scripts/corpus/pull.mjs new file mode 100644 index 0000000000..32dfddc1bd --- /dev/null +++ b/scripts/corpus/pull.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { + DEFAULT_CORPUS_ROOT, + REGISTRY_KEY, + REPO_ROOT, + applyPathFilters, + buildDocRelativePath, + coerceDocEntryFromRelativePath, + createCorpusR2Client, + ensureVisualTestDataSymlink, + formatDurationMs, + loadRegistryOrNull, + normalizePath, + printCorpusEnvHint, +} from './shared.mjs'; + +const VISUAL_LEGACY_PATH_MAP = { + 'behavior/importing/sd-1558-fld-char-issue.docx': 'fldchar/sd-1558-fld-char-issue.docx', + 'behavior/comments-tcs/nested-comments-gdocs.docx': 'comments-tcs/nested-comments-gdocs.docx', + 'behavior/comments-tcs/nested-comments-word.docx': 'comments-tcs/nested-comments-word.docx', + 'behavior/comments-tcs/sd-tracked-style-change.docx': 'comments-tcs/SD Tracked style change.docx', + 'behavior/comments-tcs/tracked-changes.docx': 'comments-tcs/tracked-changes.docx', + 'behavior/comments-tcs/gdocs-comment-on-change.docx': 'comments-tcs/gdocs-comment-on-change.docx', + 'behavior/lists/sd-1658-lists-same-level.docx': 'lists/sd-1658-lists-same-level.docx', + 'behavior/lists/sd-1543-empty-list-items.docx': 'lists/sd-1543-empty-list-items.docx', + 'behavior/formatting/sd-1778-apply-font.docx': 'other/sd-1778-apply-font.docx', + 'behavior/formatting/sd-1727-formatting-lost.docx': 'styles/sd-1727-formatting-lost.docx', + 'behavior/headers/longer-header.docx': 'basic/longer-header.docx', + 'behavior/basic-commands/h_f-normal-odd-even.docx': 'pagination/h_f-normal-odd-even.docx', + 'rendering/advanced-tables.docx': 'basic/advanced-tables.docx', +}; + +function printHelp() { + console.log(` +Usage: + node scripts/corpus/pull.mjs [options] + +Options: + --dest Local destination root (default: ${DEFAULT_CORPUS_ROOT}) + --filter Prefix filter (repeatable) + --match Substring filter (repeatable) + --exclude Exclude filter (repeatable) + --force Re-download files even if they already exist + --link-visual Point tests/visual/test-data at --dest via symlink + --dry-run Print actions without downloading + -h, --help Show this help +`); +} + +function parseArgs(argv) { + const args = { + dest: DEFAULT_CORPUS_ROOT, + filters: [], + matches: [], + excludes: [], + force: false, + linkVisual: false, + dryRun: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + if (arg === '--dest' && next) { + args.dest = path.resolve(next); + i += 1; + continue; + } + if (arg === '--filter' && next) { + args.filters.push(normalizePath(next)); + i += 1; + continue; + } + if (arg === '--match' && next) { + args.matches.push(String(next)); + i += 1; + continue; + } + if (arg === '--exclude' && next) { + args.excludes.push(normalizePath(next)); + i += 1; + continue; + } + if (arg === '--force') { + args.force = true; + continue; + } + if (arg === '--link-visual') { + args.linkVisual = true; + continue; + } + if (arg === '--dry-run') { + args.dryRun = true; + continue; + } + } + + return args; +} + +async function loadCorpusDocs(client) { + const registry = await loadRegistryOrNull(client); + if (registry?.docs?.length) { + return { + source: REGISTRY_KEY, + docs: registry.docs + .map((doc) => { + const relativePath = buildDocRelativePath(doc); + return { + ...doc, + relative_path: relativePath, + object_key: relativePath, + }; + }) + .filter((doc) => doc.relative_path && doc.relative_path.toLowerCase().endsWith('.docx')), + }; + } + + const legacyKeys = await client.listObjects('documents/'); + const docs = legacyKeys + .filter((key) => key.toLowerCase().endsWith('.docx')) + .map((key) => { + const objectKey = normalizePath(key); + const relativePath = normalizePath(key.replace(/^documents\//, '')); + return { + ...coerceDocEntryFromRelativePath(relativePath), + relative_path: relativePath, + object_key: objectKey, + }; + }); + + return { + source: 'documents/ (legacy prefix fallback)', + docs, + }; +} + +function ensureLegacyVisualAliases(destinationRoot) { + let aliasCount = 0; + for (const [legacyRelative, canonicalRelative] of Object.entries(VISUAL_LEGACY_PATH_MAP)) { + const sourcePath = path.join(destinationRoot, canonicalRelative); + if (!fs.existsSync(sourcePath)) continue; + + const aliasPath = path.join(destinationRoot, legacyRelative); + if (fs.existsSync(aliasPath)) continue; + + fs.mkdirSync(path.dirname(aliasPath), { recursive: true }); + const symlinkTarget = path.relative(path.dirname(aliasPath), sourcePath); + fs.symlinkSync(symlinkTarget, aliasPath); + aliasCount += 1; + } + return aliasCount; +} + +async function main() { + const startedAt = Date.now(); + const args = parseArgs(process.argv.slice(2)); + const destinationRoot = path.resolve(args.dest); + + const destinationRelative = path.relative(REPO_ROOT, destinationRoot); + if ( + destinationRoot === REPO_ROOT || + !destinationRelative || + destinationRelative === '.' || + destinationRelative.startsWith('..') + ) { + throw new Error(`Refusing to write corpus outside repo root: ${destinationRoot}`); + } + + const client = await createCorpusR2Client(); + + try { + console.log(`[corpus] Mode: ${client.mode}`); + console.log(`[corpus] Account: ${client.accountId}`); + console.log(`[corpus] Bucket: ${client.bucketName}`); + + const corpus = await loadCorpusDocs(client); + const relativePaths = corpus.docs.map((doc) => normalizePath(doc.relative_path)).filter(Boolean); + const selected = applyPathFilters(relativePaths, { + filters: args.filters, + matches: args.matches, + excludes: args.excludes, + }); + + const selectedSet = new Set(selected); + const selectedDocs = corpus.docs.filter((doc) => selectedSet.has(normalizePath(doc.relative_path))); + + if (selectedDocs.length === 0) { + console.log('[corpus] No docs matched filters.'); + return; + } + + fs.mkdirSync(destinationRoot, { recursive: true }); + + let downloaded = 0; + let skipped = 0; + + console.log(`[corpus] Source: ${corpus.source}`); + console.log(`[corpus] Destination: ${destinationRoot}`); + console.log(`[corpus] Matched docs: ${selectedDocs.length}`); + + for (const doc of selectedDocs) { + const relativePath = normalizePath(doc.relative_path); + const objectKey = normalizePath(doc.object_key ?? relativePath); + const destinationPath = path.join(destinationRoot, relativePath); + + if (!args.force && fs.existsSync(destinationPath)) { + skipped += 1; + continue; + } + + if (args.dryRun) { + console.log(`- ${relativePath}`); + downloaded += 1; + continue; + } + + await client.getObjectToFile(objectKey, destinationPath); + downloaded += 1; + + if (downloaded % 25 === 0 || downloaded === selectedDocs.length) { + console.log(`[corpus] Downloaded ${downloaded}/${selectedDocs.length}`); + } + } + + if (args.linkVisual && !args.dryRun) { + const aliasesAdded = ensureLegacyVisualAliases(destinationRoot); + if (aliasesAdded > 0) { + console.log(`[corpus] Added ${aliasesAdded} visual legacy alias path(s).`); + } + + const link = ensureVisualTestDataSymlink(destinationRoot); + if (link.changed) { + console.log( + `[corpus] Linked tests/visual/test-data -> ${destinationRoot}${link.backupPath ? ` (backup: ${link.backupPath})` : ''}`, + ); + } else { + console.log('[corpus] tests/visual/test-data already linked to shared corpus root.'); + } + } + + if (args.linkVisual && args.dryRun) { + console.log('[corpus] Dry run: skipped visual symlink/alias updates.'); + } + + const elapsed = Date.now() - startedAt; + console.log(`[corpus] Done. Downloaded: ${downloaded}, Skipped: ${skipped}, Elapsed: ${formatDurationMs(elapsed)}`); + } finally { + client.destroy(); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[corpus] Fatal: ${message}`); + console.error(printCorpusEnvHint()); + process.exit(1); +}); diff --git a/scripts/corpus/push.mjs b/scripts/corpus/push.mjs new file mode 100644 index 0000000000..ddfd216e0d --- /dev/null +++ b/scripts/corpus/push.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { + DOCX_CONTENT_TYPE, + REGISTRY_KEY, + buildDocRelativePath, + coerceDocEntryFromRelativePath, + createCorpusR2Client, + loadRegistryOrNull, + normalizePath, + printCorpusEnvHint, + saveRegistry, + sha256Buffer, +} from './shared.mjs'; + +function printHelp() { + console.log(` +Usage: + node scripts/corpus/push.mjs [--path ] [--folder ] [--dry-run] + +Options: + --path Relative corpus path (e.g. rendering/sd-1234-fix.docx) + --folder Convenience folder prefix when --path is omitted + --dry-run Print actions without uploading + -h, --help Show this help +`); +} + +function parseArgs(argv) { + const args = { + filePath: '', + relativePath: '', + folder: '', + dryRun: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + if (arg === '--path' && next) { + args.relativePath = next; + i += 1; + continue; + } + if (arg === '--folder' && next) { + args.folder = next; + i += 1; + continue; + } + if (arg === '--dry-run') { + args.dryRun = true; + continue; + } + if (!arg.startsWith('--') && !args.filePath) { + args.filePath = arg; + } + } + + if (!args.filePath) { + printHelp(); + throw new Error('Missing file path.'); + } + + return args; +} + +function resolveRelativeTarget({ filePath, relativePath, folder }) { + if (relativePath) { + const normalized = normalizePath(relativePath); + if (!normalized || normalized.startsWith('..')) { + throw new Error(`Invalid --path value: ${relativePath}`); + } + return normalized; + } + + const filename = path.basename(filePath); + if (!folder) return filename; + return normalizePath(path.posix.join(folder, filename)); +} + +function sortRegistryDocs(docs) { + return [...docs].sort((a, b) => + buildDocRelativePath(a).localeCompare(buildDocRelativePath(b), undefined, { + sensitivity: 'base', + }), + ); +} + +async function loadExistingRegistryForPush(client) { + const existing = await loadRegistryOrNull(client); + if (existing) return existing; + + // listObjects is prefix-based; exact-match filter to avoid false positives. + const existingKeys = await client.listObjects(REGISTRY_KEY); + const hasRegistry = existingKeys.some((key) => normalizePath(key) === REGISTRY_KEY); + if (hasRegistry) { + throw new Error( + 'Existing registry.json could not be read. Refusing to overwrite registry; fix registry.json and retry.', + ); + } + + return { updated_at: '', docs: [] }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const absoluteFile = path.resolve(args.filePath); + + if (!fs.existsSync(absoluteFile) || !fs.statSync(absoluteFile).isFile()) { + throw new Error(`File not found: ${absoluteFile}`); + } + if (path.extname(absoluteFile).toLowerCase() !== '.docx') { + throw new Error('Only .docx files are supported.'); + } + + const targetRelativePath = resolveRelativeTarget({ + filePath: absoluteFile, + relativePath: args.relativePath, + folder: args.folder, + }); + if (!targetRelativePath.toLowerCase().endsWith('.docx')) { + throw new Error('Target path must end in .docx'); + } + + const fileBuffer = fs.readFileSync(absoluteFile); + const docBase = coerceDocEntryFromRelativePath(targetRelativePath); + const nextDoc = { + ...docBase, + doc_rev: sha256Buffer(fileBuffer), + }; + + const client = await createCorpusR2Client(); + + try { + const existingRegistry = await loadExistingRegistryForPush(client); + const docs = Array.isArray(existingRegistry.docs) ? [...existingRegistry.docs] : []; + + const normalizedTarget = normalizePath(targetRelativePath).toLowerCase(); + const indexByPath = docs.findIndex((doc) => buildDocRelativePath(doc).toLowerCase() === normalizedTarget); + const indexById = docs.findIndex((doc) => doc?.doc_id === nextDoc.doc_id); + + if (indexByPath >= 0) docs[indexByPath] = { ...docs[indexByPath], ...nextDoc }; + else docs.push(nextDoc); + + if (indexById >= 0 && indexById !== indexByPath) { + docs.splice(indexById, 1); + } + + const nextRegistry = { + updated_at: new Date().toISOString(), + docs: sortRegistryDocs(docs), + }; + + console.log(`[corpus] Mode: ${client.mode}`); + console.log(`[corpus] Account: ${client.accountId}`); + console.log(`[corpus] Bucket: ${client.bucketName}`); + console.log(`[corpus] Uploading: ${absoluteFile}`); + console.log(`[corpus] Target: ${targetRelativePath}`); + console.log(`[corpus] doc_id=${nextDoc.doc_id} doc_rev=${nextDoc.doc_rev}`); + + if (args.dryRun) { + console.log('[corpus] Dry run complete (no upload performed).'); + return; + } + + await client.putObjectFromFile(targetRelativePath, absoluteFile, DOCX_CONTENT_TYPE); + await saveRegistry(client, nextRegistry); + + console.log('[corpus] Upload complete and registry.json updated.'); + } finally { + client.destroy(); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[corpus] Fatal: ${message}`); + console.error(printCorpusEnvHint()); + process.exit(1); +}); diff --git a/scripts/corpus/shared.mjs b/scripts/corpus/shared.mjs new file mode 100644 index 0000000000..5da1812487 --- /dev/null +++ b/scripts/corpus/shared.mjs @@ -0,0 +1,504 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import process from 'node:process'; +import { execFile as execFileCb } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { createRequire } from 'node:module'; + +const execFile = promisify(execFileCb); + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SCRIPT_DIR = path.dirname(SCRIPT_PATH); +export const REPO_ROOT = path.resolve(SCRIPT_DIR, '../..'); +export const DEFAULT_CORPUS_ROOT = path.join(REPO_ROOT, 'test-corpus'); +export const REGISTRY_KEY = 'registry.json'; +export const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +const DEFAULT_BUCKET_CANDIDATES = ['docx-test-corpus', 'superdoc-visual-testing']; + +const WRANGLER_CONFIG_PATHS = + process.platform === 'darwin' + ? [path.join(os.homedir(), 'Library/Preferences/.wrangler/config/default.toml')] + : [path.join(os.homedir(), '.config/.wrangler/config/default.toml')]; + +const ACCOUNT_ID_ENV_KEYS = ['SUPERDOC_CORPUS_R2_ACCOUNT_ID', 'SD_TESTING_R2_ACCOUNT_ID', 'SD_VISUAL_TESTING_R2_ACCOUNT_ID']; +const BUCKET_ENV_KEYS = [ + 'SUPERDOC_CORPUS_R2_BUCKET', + 'SD_TESTING_R2_BUCKET_NAME', + 'SD_TESTING_R2_BUCKET', + 'SD_VISUAL_TESTING_R2_BUCKET_NAME', + 'SD_VISUAL_TESTING_R2_BUCKET', +]; +const ACCESS_KEY_ID_ENV_KEYS = [ + 'SUPERDOC_CORPUS_R2_ACCESS_KEY_ID', + 'SD_TESTING_R2_ACCESS_KEY_ID', + 'SD_VISUAL_TESTING_R2_ACCESS_KEY_ID', +]; +const SECRET_ACCESS_KEY_ENV_KEYS = [ + 'SUPERDOC_CORPUS_R2_SECRET_ACCESS_KEY', + 'SD_TESTING_R2_SECRET_ACCESS_KEY', + 'SD_VISUAL_TESTING_R2_SECRET_ACCESS_KEY', +]; + +function firstEnv(names) { + for (const name of names) { + const value = process.env[name]; + if (value && String(value).trim()) return String(value).trim(); + } + return ''; +} + +export function normalizePath(value) { + return String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, ''); +} + +export function normalizeSegment(value) { + return String(value ?? '') + .trim() + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^[-_.]+|[-_.]+$/g, '') + .toLowerCase(); +} + +export function buildDocRelativePath(doc) { + if (doc?.relative_path) return normalizePath(doc.relative_path); + if (doc?.group) return normalizePath(`${doc.group}/${doc.filename}`); + return normalizePath(doc?.filename ?? ''); +} + +export function sha256Buffer(buffer) { + const hash = crypto.createHash('sha256'); + hash.update(buffer); + return `sha256:${hash.digest('hex')}`; +} + +function readWranglerConfig() { + for (const configPath of WRANGLER_CONFIG_PATHS) { + if (!fs.existsSync(configPath)) continue; + const content = fs.readFileSync(configPath, 'utf8'); + const tokenMatch = content.match(/^oauth_token\s*=\s*"(.+)"/m); + const expiryMatch = content.match(/^expiration_time\s*=\s*"(.+)"/m); + return { + path: configPath, + oauthToken: tokenMatch?.[1] ?? '', + expirationTime: expiryMatch?.[1] ?? '', + }; + } + return null; +} + +function assertWranglerToken() { + const config = readWranglerConfig(); + if (!config?.oauthToken) { + throw new Error( + 'No wrangler OAuth token found. Run `npx wrangler login` (or set SUPERDOC_CORPUS_R2_* / SD_TESTING_R2_* / SD_VISUAL_TESTING_R2_* credentials).', + ); + } + + if (config.expirationTime) { + const expiryMs = Date.parse(config.expirationTime); + if (Number.isFinite(expiryMs) && Date.now() >= expiryMs - 30_000) { + throw new Error( + `Wrangler OAuth token is expired (config: ${config.path}). Run \`npx wrangler login\` to refresh it.`, + ); + } + } + + return config.oauthToken; +} + +async function fetchCloudflareJson(url, token) { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const bodyText = await response.text(); + let parsed; + try { + parsed = bodyText ? JSON.parse(bodyText) : {}; + } catch { + parsed = null; + } + + if (!response.ok) { + throw new Error(`Cloudflare API ${response.status} for ${url}: ${bodyText || ''}`); + } + if (!parsed || parsed.success !== true) { + const errors = Array.isArray(parsed?.errors) ? parsed.errors.map((item) => item?.message).filter(Boolean) : []; + throw new Error(`Cloudflare API failed for ${url}: ${errors.join('; ') || bodyText || 'unknown error'}`); + } + + return parsed; +} + +async function resolveAccountId(token) { + const explicit = firstEnv(ACCOUNT_ID_ENV_KEYS); + if (explicit) return explicit; + + const memberships = await fetchCloudflareJson('https://api.cloudflare.com/client/v4/memberships', token); + const accountId = memberships?.result?.[0]?.account?.id; + if (!accountId) { + throw new Error('Unable to resolve Cloudflare account ID from memberships. Set SUPERDOC_CORPUS_R2_ACCOUNT_ID.'); + } + return accountId; +} + +async function resolveBucketName({ token, accountId }) { + const explicit = firstEnv(BUCKET_ENV_KEYS); + if (explicit) return explicit; + + const payload = await fetchCloudflareJson( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets`, + token, + ); + const buckets = Array.isArray(payload?.result?.buckets) ? payload.result.buckets.map((bucket) => bucket?.name).filter(Boolean) : []; + if (buckets.length === 0) { + throw new Error('No R2 buckets found for this account. Set SUPERDOC_CORPUS_R2_BUCKET explicitly.'); + } + + for (const candidate of DEFAULT_BUCKET_CANDIDATES) { + if (buckets.includes(candidate)) return candidate; + } + + if (buckets.length === 1) return buckets[0]; + + throw new Error( + `Multiple R2 buckets found (${buckets.join(', ')}). Set SUPERDOC_CORPUS_R2_BUCKET to choose one explicitly.`, + ); +} + +function isMissingWranglerBinary(error) { + const message = error instanceof Error ? error.message : String(error); + return /ENOENT|not found|command not found/i.test(message); +} + +async function runWrangler(args, { accountId }) { + const attempts = [ + { cmd: 'wrangler', args }, + { cmd: 'pnpm', args: ['exec', 'wrangler', ...args] }, + { cmd: 'npx', args: ['wrangler', ...args] }, + ]; + + let lastError = null; + + for (const attempt of attempts) { + try { + const result = await execFile(attempt.cmd, attempt.args, { + env: { + ...process.env, + ...(accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}), + }, + maxBuffer: 64 * 1024 * 1024, + }); + return result.stdout; + } catch (error) { + if (isMissingWranglerBinary(error)) { + lastError = error; + continue; + } + throw error; + } + } + + throw new Error( + `Unable to run wrangler CLI for R2 operations. Install wrangler (or fix PATH). Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ); +} + +function resolveS3Credentials() { + const accessKeyId = firstEnv(ACCESS_KEY_ID_ENV_KEYS); + const secretAccessKey = firstEnv(SECRET_ACCESS_KEY_ENV_KEYS); + + if (!accessKeyId && !secretAccessKey) return null; + + const accountId = firstEnv(ACCOUNT_ID_ENV_KEYS); + const bucketName = firstEnv(BUCKET_ENV_KEYS); + + if (!accountId || !bucketName || !accessKeyId || !secretAccessKey) { + throw new Error( + 'Incomplete S3 credential configuration. Set account, bucket, access key ID, and secret access key (SUPERDOC_CORPUS_R2_*, SD_TESTING_R2_*, or SD_VISUAL_TESTING_R2_*).', + ); + } + + return { + accountId, + bucketName, + accessKeyId, + secretAccessKey, + }; +} + +async function importAwsSdkClient() { + const candidates = [ + createRequire(import.meta.url), + createRequire(path.join(REPO_ROOT, 'tests/visual/package.json')), + ]; + + for (const req of candidates) { + try { + const resolvedPath = req.resolve('@aws-sdk/client-s3'); + return import(pathToFileURL(resolvedPath).href); + } catch { + // try next candidate + } + } + + throw new Error( + 'Unable to resolve @aws-sdk/client-s3. Run `pnpm install` at repo root (or in tests/visual workspace package).', + ); +} + +async function createS3R2Client(config) { + const sdk = await importAwsSdkClient(); + const { S3Client, ListObjectsV2Command, GetObjectCommand, PutObjectCommand } = sdk; + + const s3 = new S3Client({ + region: 'auto', + endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); + + const bucketName = config.bucketName; + + return { + accountId: config.accountId, + bucketName, + mode: 's3', + async listObjects(prefix = '') { + const keys = []; + let continuationToken; + do { + const response = await s3.send( + new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + for (const item of response.Contents ?? []) { + if (item.Key) keys.push(item.Key); + } + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + } while (continuationToken); + return keys; + }, + async getObjectBuffer(key) { + const response = await s3.send(new GetObjectCommand({ Bucket: bucketName, Key: key })); + if (!response.Body) throw new Error(`Missing body for s3://${bucketName}/${key}`); + const bytes = await response.Body.transformToByteArray(); + return Buffer.from(bytes); + }, + async getObjectToFile(key, destinationPath) { + const buffer = await this.getObjectBuffer(key); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, buffer); + }, + async putObjectBuffer(key, body, contentType) { + await s3.send( + new PutObjectCommand({ + Bucket: bucketName, + Key: key, + Body: body, + ContentType: contentType, + }), + ); + }, + async putObjectFromFile(key, filePath, contentType) { + const body = fs.readFileSync(filePath); + await this.putObjectBuffer(key, body, contentType); + }, + destroy() { + s3.destroy(); + }, + }; +} + +async function createWranglerR2Client() { + const token = assertWranglerToken(); + const accountId = await resolveAccountId(token); + const bucketName = await resolveBucketName({ token, accountId }); + + const listObjects = async (prefix = '') => { + const keys = []; + let cursor = ''; + + do { + const params = new URLSearchParams(); + if (prefix) params.set('prefix', prefix); + if (cursor) params.set('cursor', cursor); + const payload = await fetchCloudflareJson( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${bucketName}/objects?${params}`, + token, + ); + + const result = Array.isArray(payload?.result) ? payload.result : []; + for (const item of result) { + if (item?.key) keys.push(item.key); + } + + cursor = payload?.result_info?.cursor ?? ''; + } while (cursor); + + return keys; + }; + + const getObjectToFile = async (key, destinationPath) => { + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + await runWrangler(['r2', 'object', 'get', `${bucketName}/${key}`, '--file', destinationPath, '--remote'], { + accountId, + }); + }; + + const putObjectFromFile = async (key, filePath, contentType) => { + const args = ['r2', 'object', 'put', `${bucketName}/${key}`, '--file', filePath, '--remote']; + if (contentType) { + args.push('--content-type', contentType); + } + await runWrangler(args, { accountId }); + }; + + return { + accountId, + bucketName, + mode: 'wrangler', + listObjects, + async getObjectToFile(key, destinationPath) { + return getObjectToFile(key, destinationPath); + }, + async getObjectBuffer(key) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-get-')); + const tmpFile = path.join(tmpDir, 'object.bin'); + try { + await getObjectToFile(key, tmpFile); + return fs.readFileSync(tmpFile); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + async putObjectFromFile(key, filePath, contentType) { + return putObjectFromFile(key, filePath, contentType); + }, + async putObjectBuffer(key, body, contentType) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-put-')); + const tmpFile = path.join(tmpDir, 'object.bin'); + try { + fs.writeFileSync(tmpFile, body); + await putObjectFromFile(key, tmpFile, contentType); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + destroy() { + // no-op + }, + }; +} + +export async function createCorpusR2Client() { + const s3Config = resolveS3Credentials(); + if (s3Config) { + return createS3R2Client(s3Config); + } + return createWranglerR2Client(); +} + +export async function loadRegistryOrNull(client) { + try { + const raw = await client.getObjectBuffer(REGISTRY_KEY); + const parsed = JSON.parse(raw.toString('utf8')); + if (!parsed || !Array.isArray(parsed.docs)) { + throw new Error('Invalid registry.json format (missing docs array).'); + } + return parsed; + } catch { + return null; + } +} + +export async function saveRegistry(client, registry) { + const body = Buffer.from(`${JSON.stringify(registry, null, 2)}\n`, 'utf8'); + await client.putObjectBuffer(REGISTRY_KEY, body, 'application/json'); +} + +export function ensureVisualTestDataSymlink(corpusRoot) { + const visualDataPath = path.join(REPO_ROOT, 'tests', 'visual', 'test-data'); + const absoluteCorpusRoot = path.resolve(corpusRoot); + const symlinkTarget = path.relative(path.dirname(visualDataPath), absoluteCorpusRoot); + + let stat = null; + try { + // lstat() detects existing symlink entries even if their targets are missing. + stat = fs.lstatSync(visualDataPath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + + if (stat) { + if (stat.isSymbolicLink()) { + const existingTarget = fs.readlinkSync(visualDataPath); + const existingResolved = path.resolve(path.dirname(visualDataPath), existingTarget); + if (existingResolved === absoluteCorpusRoot) { + return { linked: true, changed: false, backupPath: null }; + } + fs.rmSync(visualDataPath, { recursive: true, force: true }); + } else { + const backupPath = `${visualDataPath}.backup-${Date.now()}`; + fs.renameSync(visualDataPath, backupPath); + fs.symlinkSync(symlinkTarget, visualDataPath, 'dir'); + return { linked: true, changed: true, backupPath }; + } + } + + fs.mkdirSync(path.dirname(visualDataPath), { recursive: true }); + fs.symlinkSync(symlinkTarget, visualDataPath, 'dir'); + return { linked: true, changed: true, backupPath: null }; +} + +export function applyPathFilters(paths, { filters = [], matches = [], excludes = [] } = {}) { + const normalizedFilters = filters.map((value) => String(value).toLowerCase()).filter(Boolean); + const normalizedMatches = matches.map((value) => String(value).toLowerCase()).filter(Boolean); + const normalizedExcludes = excludes.map((value) => String(value).toLowerCase()).filter(Boolean); + + return paths.filter((candidate) => { + const value = candidate.toLowerCase(); + const matchesPrefix = normalizedFilters.length === 0 || normalizedFilters.some((filter) => value.startsWith(filter)); + const matchesSubstring = normalizedMatches.length === 0 || normalizedMatches.some((match) => value.includes(match)); + const excluded = normalizedExcludes.some((exclude) => value.startsWith(exclude) || value.includes(exclude)); + return matchesPrefix && matchesSubstring && !excluded; + }); +} + +export function printCorpusEnvHint() { + const lines = [ + 'Auth options:', + '- Local (recommended): `npx wrangler login`', + '- CI / explicit creds: set SUPERDOC_CORPUS_R2_* (or SD_TESTING_R2_* / SD_VISUAL_TESTING_R2_*)', + '- Optional explicit bucket: SUPERDOC_CORPUS_R2_BUCKET', + ]; + return lines.join('\n'); +} + +export function formatDurationMs(ms) { + return `${(ms / 1000).toFixed(2)}s`; +} + +export function coerceDocEntryFromRelativePath(relativePath) { + const filename = path.basename(relativePath); + const group = relativePath.includes('/') ? relativePath.split('/')[0] : undefined; + const relativeStem = relativePath.replace(/\.docx$/i, ''); + return { + doc_id: normalizeSegment(relativeStem.replace(/\//g, '-')), + filename, + group, + relative_path: relativePath, + }; +} diff --git a/tests/layout-snapshots/README.md b/tests/layout-snapshots/README.md new file mode 100644 index 0000000000..423118e603 --- /dev/null +++ b/tests/layout-snapshots/README.md @@ -0,0 +1,132 @@ +# Layout Snapshot Exporter + +Exports layout JSON for every `.docx` under: + +- `/test-corpus` + +into candidate snapshots at: + +- `/tests/layout-snapshots/candidate` + +while preserving subdirectories and source filename identity. + +Prerequisites: + +- Run commands from the repo root with `pnpm`. +- Pull the corpus before running any snapshot command: `pnpm corpus:pull`. + +Important: + +- The exporter wipes the output directory at start of every run, then regenerates all snapshots. +- Editor telemetry is disabled by default. +- Default pipeline is `headless` (no `PresentationEditor` painter path, faster for batch generation). +- Use `--jobs N` to process documents in parallel worker processes. +- Each processed doc logs in a 3-line block (`doc`, `pages+took`, `phases`). +- Long log lines wrap at 120 chars instead of being truncated. +- `Complete in ...` is printed as the final line of output. +- End-of-run output includes average time and phase totals. + +Candidate output naming: + +- `path/to/file.docx` -> `candidate/path/to/file.docx.layout.json` + +## Run + +```bash +# One-time setup (repeat whenever corpus contents change) +pnpm corpus:pull + +pnpm layout:snapshots +``` + +## Common commands + +```bash +# Fast headless generation (default via package script) +pnpm layout:snapshots + +# Limit sample size while iterating +pnpm layout:snapshots -- --limit 10 --jobs 2 + +# Fallback to PresentationEditor path for comparison +pnpm layout:snapshots -- --pipeline presentation --jobs 1 + +# Telemetry controls +pnpm layout:snapshots -- --telemetry off +pnpm layout:snapshots -- --enable-telemetry +``` + +If native `canvas` is unavailable in your runtime, the script falls back to a mock canvas and warns that metrics are approximate. + +## Generate from npm version + +Use the wrapper script to install any published `superdoc` version/tag from npm, then run snapshot export against it. + +```bash +# Install superdoc@1.12.0 in a temp dir and export to reference/v.1.12.0 +pnpm layout:snapshots:npm -- 1.12.0 + +# Use npm tag +pnpm layout:snapshots:npm -- latest + +# Fast smoke run +pnpm layout:snapshots:npm -- 1.12.0 --limit 10 --jobs 2 +``` + +Versioned reference output root: + +- `/tests/layout-snapshots/reference/v./...` + +Notes: + +- Telemetry is forced off in this wrapper. +- The target version folder is wiped and regenerated on each run. +- The script prints the final version folder path at the end. + +## Compare candidate vs reference + +Generate a diff report between: + +- candidate snapshots at `tests/layout-snapshots/candidate` +- reference snapshots at `tests/layout-snapshots/reference/v.` + +The compare script regenerates candidate snapshots before every run (full refresh by default), and auto-generates the +reference version when missing. References are only regenerated when missing/incomplete. + +When changed docs are detected, compare now automatically runs `devtools/visual-testing` in local mode for only those +changed docs, using the same reference version as the visual baseline. + +```bash +# Compare against npm superdoc@next (default when --reference is omitted) +pnpm layout:compare + +# Compare against a specific reference version (auto-generates reference if missing) +pnpm layout:compare -- --reference 1.13.0-next.15 + +# Disable auto visual post-step +pnpm layout:compare -- --reference 1.13.0-next.15 --no-visual-on-change + +# Fail with non-zero exit if any diffs/missing files are found +pnpm layout:compare -- --reference 1.13.0-next.15 --fail-on-diff +``` + +Reports are written under: + +- `/tests/layout-snapshots/reports/-v.-vs-candidate/` +- plus per-document diff files under the report's `docs/` folder + +## Using packed `superdoc.tgz` + +If you want to run against a packed build: + +1. Build package tarball: + +```bash +pnpm run pack:es +``` + +2. Point exporter at your installed module: + +```bash +pnpm layout:snapshots -- --module superdoc/super-editor --jobs 4 +``` diff --git a/tests/layout-snapshots/compare-layout-snapshots.mjs b/tests/layout-snapshots/compare-layout-snapshots.mjs new file mode 100644 index 0000000000..ccb9bcd9cb --- /dev/null +++ b/tests/layout-snapshots/compare-layout-snapshots.mjs @@ -0,0 +1,1166 @@ +#!/usr/bin/env node + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import readline from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { normalizeVersionLabel } from './shared.mjs'; + +const cloneDeep = typeof structuredClone === 'function' + ? structuredClone + : (obj) => JSON.parse(JSON.stringify(obj)); + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SCRIPT_DIR = path.dirname(SCRIPT_PATH); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '../..'); + +const DEFAULT_CANDIDATE_ROOT = path.join(REPO_ROOT, 'tests', 'layout-snapshots', 'candidate'); +const DEFAULT_REFERENCE_BASE = path.join(REPO_ROOT, 'tests', 'layout-snapshots', 'reference'); +const DEFAULT_REPORTS_ROOT = path.join(REPO_ROOT, 'tests', 'layout-snapshots', 'reports'); +const DEFAULT_VISUAL_WORKDIR = path.join(REPO_ROOT, 'devtools', 'visual-testing'); +const CANDIDATE_EXPORT_SCRIPT_PATH = path.join(SCRIPT_DIR, 'export-layout-snapshots.mjs'); +const NPM_EXPORT_SCRIPT_PATH = path.join(SCRIPT_DIR, 'export-layout-snapshots-npm.mjs'); +const NPM_PACKAGE_NAME = 'superdoc'; +const DEFAULT_NPM_DIST_TAG = 'next'; + +function printHelp() { + console.log(` +Usage: + bun tests/layout-snapshots/compare-layout-snapshots.mjs [--reference | --reference-root ] [options] + +Options: + --reference Reference version label/spec (default: npm ${NPM_PACKAGE_NAME}@${DEFAULT_NPM_DIST_TAG}) + --reference-root Use explicit reference folder path instead of --reference + --reference-base Reference base folder (default: ${DEFAULT_REFERENCE_BASE}) + --candidate-root Candidate folder (default: ${DEFAULT_CANDIDATE_ROOT}) + --reports-root Reports parent folder (default: ${DEFAULT_REPORTS_ROOT}) + --report-dir Exact report output folder (default: auto timestamped) + --auto-generate-candidate Regenerate candidate snapshots before compare (default: on) + --no-auto-generate-candidate Do not regenerate candidate snapshots before compare + --auto-generate-reference Generate missing reference snapshots automatically (default: on) + --no-auto-generate-reference Do not auto-generate missing reference snapshots + --jobs Worker count if auto-generating snapshots/references (default: 4) + --pipeline headless | presentation for auto-generation (default: headless) + --installer auto | bun | npm for auto-generation (default: auto) + --input-root Input docs root for auto-generation + --numeric-tolerance Number comparison tolerance (default: 0.001) + --max-diff-entries Max diff entries per doc (default: 2000) + --visual-on-change Run visual compare for changed docs after layout compare (default: on) + --no-visual-on-change Disable visual compare post-step + --visual-reference Visual baseline version (default: same as --reference) + --visual-workdir devtools/visual-testing root (default: ${DEFAULT_VISUAL_WORKDIR}) + --visual-browser Browser for visual compare (default: chromium) + --visual-threshold Visual diff threshold percent + --fail-on-diff Exit with code 1 when diffs or missing docs are found + -h, --help Show this help + +Examples: + bun tests/layout-snapshots/compare-layout-snapshots.mjs --reference 1.13.0-next.15 + bun tests/layout-snapshots/compare-layout-snapshots.mjs --reference 1.13.0-next.15 --fail-on-diff + bun tests/layout-snapshots/compare-layout-snapshots.mjs --reference-root ./tests/layout-snapshots/reference/v.1.13.0-next.15 +`); +} + +function formatTimestamp(date) { + return date.toISOString().replace(/[:.]/g, '-'); +} + +function safeLabel(value) { + return String(value ?? '') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') || 'unknown'; +} + +function pathToPosix(value) { + return value.split(path.sep).join('/'); +} + +function formatPath(segments) { + if (!Array.isArray(segments) || segments.length === 0) return '$'; + let out = '$'; + for (const segment of segments) { + if (typeof segment === 'number') out += `[${segment}]`; + else if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment)) out += `.${segment}`; + else out += `[${JSON.stringify(segment)}]`; + } + return out; +} + +function summarizeValue(value) { + if (value === undefined) return undefined; + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + try { + const encoded = JSON.stringify(value); + if (encoded.length <= 220) return encoded; + return `${encoded.slice(0, 217)}...`; + } catch { + return String(value); + } +} + +function parseArgs(argv) { + const args = { + reference: null, + referenceRoot: null, + referenceBase: DEFAULT_REFERENCE_BASE, + candidateRoot: DEFAULT_CANDIDATE_ROOT, + reportsRoot: DEFAULT_REPORTS_ROOT, + reportDir: null, + autoGenerateCandidate: true, + autoGenerateReference: true, + jobs: 4, + pipeline: 'headless', + installer: 'auto', + inputRoot: null, + numericTolerance: 0.001, + maxDiffEntries: 2000, + visualOnChange: true, + visualReference: null, + visualWorkdir: DEFAULT_VISUAL_WORKDIR, + visualBrowser: 'chromium', + visualThreshold: null, + failOnDiff: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + if (arg === '--reference' && next) { + args.reference = next; + i += 1; + continue; + } + if (arg === '--reference-root' && next) { + args.referenceRoot = next; + i += 1; + continue; + } + if (arg === '--reference-base' && next) { + args.referenceBase = next; + i += 1; + continue; + } + if (arg === '--candidate-root' && next) { + args.candidateRoot = next; + i += 1; + continue; + } + if (arg === '--reports-root' && next) { + args.reportsRoot = next; + i += 1; + continue; + } + if (arg === '--report-dir' && next) { + args.reportDir = next; + i += 1; + continue; + } + if (arg === '--auto-generate-candidate') { + args.autoGenerateCandidate = true; + continue; + } + if (arg === '--no-auto-generate-candidate') { + args.autoGenerateCandidate = false; + continue; + } + if (arg === '--auto-generate-reference') { + args.autoGenerateReference = true; + continue; + } + if (arg === '--no-auto-generate-reference') { + args.autoGenerateReference = false; + continue; + } + if (arg === '--jobs' && next) { + const parsed = Number(next); + if (!Number.isFinite(parsed) || parsed < 1) { + throw new Error(`Invalid --jobs value "${next}".`); + } + args.jobs = Math.floor(parsed); + i += 1; + continue; + } + if (arg === '--pipeline' && next) { + const normalized = String(next).toLowerCase(); + if (normalized !== 'headless' && normalized !== 'presentation') { + throw new Error(`Invalid --pipeline value "${next}".`); + } + args.pipeline = normalized; + i += 1; + continue; + } + if (arg === '--installer' && next) { + const normalized = String(next).toLowerCase(); + if (!['auto', 'bun', 'npm'].includes(normalized)) { + throw new Error(`Invalid --installer value "${next}".`); + } + args.installer = normalized; + i += 1; + continue; + } + if (arg === '--input-root' && next) { + args.inputRoot = next; + i += 1; + continue; + } + if (arg === '--numeric-tolerance' && next) { + const parsed = Number(next); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid --numeric-tolerance value "${next}".`); + } + args.numericTolerance = parsed; + i += 1; + continue; + } + if (arg === '--max-diff-entries' && next) { + const parsed = Number(next); + if (!Number.isFinite(parsed) || parsed < 1) { + throw new Error(`Invalid --max-diff-entries value "${next}".`); + } + args.maxDiffEntries = Math.floor(parsed); + i += 1; + continue; + } + if (arg === '--visual-on-change') { + args.visualOnChange = true; + continue; + } + if (arg === '--no-visual-on-change') { + args.visualOnChange = false; + continue; + } + if (arg === '--visual-reference' && next) { + args.visualReference = next; + i += 1; + continue; + } + if (arg === '--visual-workdir' && next) { + args.visualWorkdir = next; + i += 1; + continue; + } + if (arg === '--visual-browser' && next) { + args.visualBrowser = next; + i += 1; + continue; + } + if (arg === '--visual-threshold' && next) { + const parsed = Number(next); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid --visual-threshold value "${next}".`); + } + args.visualThreshold = parsed; + i += 1; + continue; + } + if (arg === '--fail-on-diff') { + args.failOnDiff = true; + continue; + } + } + + return args; +} + +async function resolveNpmDistTagVersion({ packageName, distTag }) { + const encodedPackage = encodeURIComponent(packageName); + const url = `https://registry.npmjs.org/-/package/${encodedPackage}/dist-tags`; + let response; + try { + response = await fetch(url, { + headers: { + accept: 'application/json', + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to fetch npm dist-tag "${distTag}" for ${packageName}: ${message}`); + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `npm dist-tag lookup failed for ${packageName}@${distTag}: HTTP ${response.status}${body ? ` (${body.slice(0, 180)})` : ''}`, + ); + } + + const payload = await response.json().catch(() => null); + const resolvedVersion = payload?.[distTag]; + if (typeof resolvedVersion !== 'string' || !resolvedVersion.trim()) { + throw new Error(`npm dist-tag "${distTag}" is not set for package "${packageName}".`); + } + return resolvedVersion.trim(); +} + +async function resolveGeneratedReferenceRoot({ generatedFolder, referenceBase, reference, errorContext }) { + if (generatedFolder && (await pathExists(generatedFolder))) { + return { root: path.resolve(generatedFolder), label: path.basename(path.resolve(generatedFolder)) }; + } + const fallback = path.join(referenceBase, normalizeVersionLabel(reference)); + if (!(await pathExists(fallback))) { + throw new Error(`${errorContext} completed but folder not found: ${fallback}`); + } + return { root: fallback, label: path.basename(fallback) }; +} + +async function pathExists(targetPath) { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function listSnapshotFiles(rootPath) { + const entries = new Map(); + const root = path.resolve(rootPath); + + async function walk(current) { + const dirEntries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of dirEntries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.layout.json')) continue; + const rel = pathToPosix(path.relative(root, fullPath)); + entries.set(rel, fullPath); + } + } + + if (!(await pathExists(root))) { + return entries; + } + await walk(root); + return entries; +} + +function buildPathRelation(candidatePaths, referencePaths) { + const candidateSet = new Set(candidatePaths); + const referenceSet = new Set(referencePaths); + + return { + matched: candidatePaths.filter((relPath) => referenceSet.has(relPath)), + missingInReference: candidatePaths.filter((relPath) => !referenceSet.has(relPath)), + missingInCandidate: referencePaths.filter((relPath) => !candidateSet.has(relPath)), + }; +} + +function normalizeDocSnapshot(raw) { + const layoutSnapshot = cloneDeep(raw?.layoutSnapshot ?? {}); + const blocks = Array.isArray(layoutSnapshot.blocks) ? layoutSnapshot.blocks : []; + const idMap = new Map(); + + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + const nextId = `b${i}`; + if (block && typeof block === 'object' && typeof block.id === 'string') { + idMap.set(block.id, nextId); + block.id = nextId; + } else if (block && typeof block === 'object') { + block.id = nextId; + } + } + + const pages = layoutSnapshot?.layout?.pages; + if (Array.isArray(pages)) { + for (const page of pages) { + const fragments = page?.fragments; + if (!Array.isArray(fragments)) continue; + for (const fragment of fragments) { + if (!fragment || typeof fragment !== 'object') continue; + if (typeof fragment.blockId !== 'string') continue; + fragment.blockId = idMap.get(fragment.blockId) ?? fragment.blockId; + } + } + } + + const shouldDropNonVisualField = (pathSegments) => { + const key = pathSegments[pathSegments.length - 1]; + const parent = pathSegments[pathSegments.length - 2]; + + if (key === 'anchorParagraphId') return true; + if (key === 'pmStart' || key === 'pmEnd') return true; + if (key === 'id' && parent === 'trackedChange') return true; + if (key === 'sdBlockId' && parent === 'sdt') return true; + if (key === 'rId' && parent === 'link') return true; + return false; + }; + + const stripNonVisualMetadata = (node, pathSegments = []) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i += 1) { + stripNonVisualMetadata(node[i], [...pathSegments, i]); + } + return; + } + + for (const key of Object.keys(node)) { + const nextPath = [...pathSegments, key]; + if (shouldDropNonVisualField(nextPath)) { + delete node[key]; + continue; + } + stripNonVisualMetadata(node[key], nextPath); + } + }; + + stripNonVisualMetadata(layoutSnapshot, ['layoutSnapshot']); + + return { + formatVersion: raw?.formatVersion ?? null, + source: { + docxRelativePath: raw?.source?.docxRelativePath ?? null, + }, + runtime: { + pipeline: raw?.runtime?.pipeline ?? null, + mode: raw?.runtime?.mode ?? null, + usingStubCanvas: raw?.runtime?.usingStubCanvas ?? null, + }, + layoutOptions: raw?.layoutOptions ?? null, + layoutSnapshot, + }; +} + +function getPagesByBlockIndex(normalizedDoc) { + const map = new Map(); + const pages = normalizedDoc?.layoutSnapshot?.layout?.pages; + if (!Array.isArray(pages)) return map; + + for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) { + const page = pages[pageIndex]; + const fragments = Array.isArray(page?.fragments) ? page.fragments : []; + for (const fragment of fragments) { + const id = fragment?.blockId; + if (typeof id !== 'string') continue; + const match = id.match(/^b(\d+)$/); + if (!match) continue; + const blockIndex = Number(match[1]); + if (!Number.isFinite(blockIndex)) continue; + if (!map.has(blockIndex)) map.set(blockIndex, new Set()); + map.get(blockIndex).add(pageIndex + 1); + } + } + + return map; +} + +function collectDiffs(referenceValue, candidateValue, options) { + const { numericTolerance, maxDiffEntries } = options; + const diffs = []; + let truncated = false; + + function pushDiff(entry) { + if (diffs.length >= maxDiffEntries) { + truncated = true; + return false; + } + diffs.push(entry); + return true; + } + + function walk(referenceNode, candidateNode, pathSegments) { + if (diffs.length >= maxDiffEntries) { + truncated = true; + return; + } + + if (referenceNode === candidateNode) return; + + if ( + typeof referenceNode === 'number' && + typeof candidateNode === 'number' && + Number.isFinite(referenceNode) && + Number.isFinite(candidateNode) + ) { + if (Math.abs(referenceNode - candidateNode) <= numericTolerance) return; + pushDiff({ + pathSegments, + kind: 'changed', + reference: referenceNode, + candidate: candidateNode, + delta: candidateNode - referenceNode, + }); + return; + } + + const referenceType = Array.isArray(referenceNode) ? 'array' : referenceNode === null ? 'null' : typeof referenceNode; + const candidateType = Array.isArray(candidateNode) ? 'array' : candidateNode === null ? 'null' : typeof candidateNode; + if (referenceType !== candidateType) { + pushDiff({ + pathSegments, + kind: 'type-mismatch', + reference: summarizeValue(referenceNode), + candidate: summarizeValue(candidateNode), + }); + return; + } + + if (Array.isArray(referenceNode)) { + if (referenceNode.length !== candidateNode.length) { + pushDiff({ + pathSegments, + kind: 'array-length', + reference: referenceNode.length, + candidate: candidateNode.length, + }); + } + const length = Math.min(referenceNode.length, candidateNode.length); + for (let i = 0; i < length; i += 1) { + walk(referenceNode[i], candidateNode[i], [...pathSegments, i]); + if (diffs.length >= maxDiffEntries) break; + } + return; + } + + if (referenceNode && typeof referenceNode === 'object') { + const keys = new Set([...Object.keys(referenceNode), ...Object.keys(candidateNode)]); + for (const key of [...keys].sort()) { + if (!(key in referenceNode)) { + if ( + !pushDiff({ + pathSegments: [...pathSegments, key], + kind: 'missing-in-reference', + reference: undefined, + candidate: summarizeValue(candidateNode[key]), + }) + ) break; + continue; + } + if (!(key in candidateNode)) { + if ( + !pushDiff({ + pathSegments: [...pathSegments, key], + kind: 'missing-in-candidate', + reference: summarizeValue(referenceNode[key]), + candidate: undefined, + }) + ) break; + continue; + } + walk(referenceNode[key], candidateNode[key], [...pathSegments, key]); + if (diffs.length >= maxDiffEntries) break; + } + return; + } + + pushDiff({ + pathSegments, + kind: 'changed', + reference: referenceNode, + candidate: candidateNode, + }); + } + + walk(referenceValue, candidateValue, []); + return { diffs, truncated }; +} + +function groupDiffsByPage(diffEntries, blockPagesMap) { + const global = []; + const perPage = new Map(); + + function addPageDiff(pageNumber, entry) { + if (!perPage.has(pageNumber)) perPage.set(pageNumber, []); + perPage.get(pageNumber).push(entry); + } + + for (const entry of diffEntries) { + const pathSegments = entry.pathSegments; + const pagePathIdx = + pathSegments.length >= 4 && + pathSegments[0] === 'layoutSnapshot' && + pathSegments[1] === 'layout' && + pathSegments[2] === 'pages' && + typeof pathSegments[3] === 'number' + ? pathSegments[3] + : null; + + if (pagePathIdx != null) { + addPageDiff(pagePathIdx + 1, entry); + continue; + } + + const blockPathIdx = + pathSegments.length >= 3 && + pathSegments[0] === 'layoutSnapshot' && + (pathSegments[1] === 'blocks' || pathSegments[1] === 'measures') && + typeof pathSegments[2] === 'number' + ? pathSegments[2] + : null; + + if (blockPathIdx != null && blockPagesMap.has(blockPathIdx)) { + for (const pageNumber of [...blockPagesMap.get(blockPathIdx)].sort((a, b) => a - b)) { + addPageDiff(pageNumber, entry); + } + continue; + } + + global.push(entry); + } + + return { + global, + perPage, + }; +} + +async function runNpmReferenceGeneration({ referenceSpecifier, args }) { + const childArgs = [ + NPM_EXPORT_SCRIPT_PATH, + referenceSpecifier, + '--output-base', + path.resolve(args.referenceBase), + '--jobs', + String(args.jobs), + '--pipeline', + args.pipeline, + '--installer', + args.installer, + ]; + if (args.inputRoot) { + childArgs.push('--input-root', path.resolve(args.inputRoot)); + } + + const child = spawn(process.execPath, childArgs, { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let resolvedVersionFolder = null; + const collectLine = (line) => { + const trimmed = String(line ?? '').trim(); + const match = trimmed.match(/^\[layout-snapshots:npm\] Version folder:\s*(.+)$/); + if (match) { + resolvedVersionFolder = match[1].trim(); + } + }; + + const stdoutRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + const stderrRl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity }); + + const stdoutDone = (async () => { + for await (const line of stdoutRl) { + console.log(line); + collectLine(line); + } + })(); + const stderrDone = (async () => { + for await (const line of stderrRl) { + console.error(line); + collectLine(line); + } + })(); + + const exitCode = await new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', () => resolve(1)); + }); + + await Promise.all([stdoutDone, stderrDone]); + + if (exitCode !== 0) { + throw new Error(`Reference generation failed with exit code ${exitCode}.`); + } + + return resolvedVersionFolder; +} + +async function runCandidateGeneration({ candidateRoot, args }) { + const childArgs = [ + CANDIDATE_EXPORT_SCRIPT_PATH, + '--output-root', + path.resolve(candidateRoot), + '--jobs', + String(args.jobs), + '--pipeline', + args.pipeline, + '--disable-telemetry', + ]; + if (args.inputRoot) { + childArgs.push('--input-root', path.resolve(args.inputRoot)); + } + + const child = spawn(process.execPath, childArgs, { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const stdoutRl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + const stderrRl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity }); + + const stdoutDone = (async () => { + for await (const line of stdoutRl) { + console.log(line); + } + })(); + const stderrDone = (async () => { + for await (const line of stderrRl) { + console.error(line); + } + })(); + + const exitCode = await new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', () => resolve(1)); + }); + + await Promise.all([stdoutDone, stderrDone]); + + if (exitCode !== 0) { + throw new Error(`Candidate generation failed with exit code ${exitCode}.`); + } +} + +function snapshotPathToDocxRelativePath(snapshotRelativePath) { + if (typeof snapshotRelativePath !== 'string') return null; + if (!snapshotRelativePath.endsWith('.layout.json')) return null; + return snapshotRelativePath.slice(0, -'.layout.json'.length); +} + +function collectChangedDocRelativePaths(changedDocs) { + const uniquePaths = new Set(); + for (const entry of changedDocs) { + const docRelativePath = snapshotPathToDocxRelativePath(entry?.path); + if (!docRelativePath) continue; + uniquePaths.add(docRelativePath); + } + return [...uniquePaths].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); +} + +async function runVisualCompareForChangedDocs({ changedDocPaths, args }) { + const visualWorkdir = path.resolve(args.visualWorkdir); + const visualPackagePath = path.join(visualWorkdir, 'package.json'); + if (!(await pathExists(visualPackagePath))) { + throw new Error(`Visual testing workspace not found: ${visualWorkdir}`); + } + + const visualReference = args.visualReference ?? args.reference; + if (!visualReference) { + throw new Error('Visual compare requires --reference (or explicit --visual-reference).'); + } + + const visualDocsRoot = args.inputRoot ? path.resolve(args.inputRoot) : path.join(REPO_ROOT, 'test-corpus'); + const commandArgs = ['compare:visual', visualReference, '--local', '--docs', visualDocsRoot]; + + if (args.visualBrowser) { + commandArgs.push('--browser', args.visualBrowser); + } + if (typeof args.visualThreshold === 'number') { + commandArgs.push('--threshold', String(args.visualThreshold)); + } + for (const docPath of changedDocPaths) { + commandArgs.push('--doc', docPath); + } + + console.log(`[layout-snapshots:compare] Visual workdir: ${visualWorkdir}`); + console.log(`[layout-snapshots:compare] Visual docs root: ${visualDocsRoot}`); + console.log(`[layout-snapshots:compare] Visual reference: ${visualReference}`); + console.log(`[layout-snapshots:compare] Visual docs count: ${changedDocPaths.length}`); + + const child = spawn('pnpm', commandArgs, { + cwd: visualWorkdir, + env: { + ...process.env, + ...(process.stdout.isTTY ? {} : { CI: process.env.CI ?? 'true' }), + }, + stdio: 'inherit', + }); + + const exitCode = await new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', () => resolve(1)); + }); + + if (exitCode !== 0) { + throw new Error(`Visual compare failed with exit code ${exitCode}.`); + } + + return { + workdir: visualWorkdir, + docsRoot: visualDocsRoot, + reference: visualReference, + docCount: changedDocPaths.length, + }; +} + +function buildReportMarkdown(summary) { + const lines = []; + lines.push('# Layout Snapshot Diff Report'); + lines.push(''); + lines.push(`- Generated: ${summary.generatedAt}`); + lines.push(`- Candidate root: ${summary.candidateRoot}`); + lines.push(`- Reference root: ${summary.referenceRoot}`); + lines.push(`- Candidate docs: ${summary.candidateDocCount}`); + lines.push(`- Reference docs: ${summary.referenceDocCount}`); + lines.push(`- Matched docs: ${summary.matchedDocCount}`); + lines.push(`- Changed docs: ${summary.changedDocCount}`); + lines.push(`- Unchanged docs: ${summary.unchangedDocCount}`); + lines.push(`- Missing in reference: ${summary.missingInReference.length}`); + lines.push(`- Missing in candidate: ${summary.missingInCandidate.length}`); + lines.push(''); + + if (summary.missingInReference.length > 0) { + lines.push('## Missing In Reference'); + lines.push(''); + for (const relPath of summary.missingInReference) { + lines.push(`- ${relPath}`); + } + lines.push(''); + } + + if (summary.missingInCandidate.length > 0) { + lines.push('## Missing In Candidate'); + lines.push(''); + for (const relPath of summary.missingInCandidate) { + lines.push(`- ${relPath}`); + } + lines.push(''); + } + + if (summary.changedDocs.length > 0) { + lines.push('## Changed Docs'); + lines.push(''); + for (const item of summary.changedDocs) { + const pages = item.pagesChanged.length > 0 ? item.pagesChanged.join(', ') : 'global-only'; + lines.push(`- ${item.path} | diffs: ${item.diffCount} | pages: ${pages}`); + } + lines.push(''); + } + + return `${lines.join('\n')}\n`; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const candidateRoot = path.resolve(args.candidateRoot); + const referenceBase = path.resolve(args.referenceBase); + + if (!args.reference && !args.referenceRoot) { + console.log( + `[layout-snapshots:compare] No --reference provided. Resolving npm dist-tag "${DEFAULT_NPM_DIST_TAG}" for ${NPM_PACKAGE_NAME}...`, + ); + args.reference = await resolveNpmDistTagVersion({ + packageName: NPM_PACKAGE_NAME, + distTag: DEFAULT_NPM_DIST_TAG, + }); + console.log(`[layout-snapshots:compare] Resolved default reference: ${args.reference}`); + } + + let referenceRoot = args.referenceRoot ? path.resolve(args.referenceRoot) : null; + let resolvedReferenceLabel = args.reference ? normalizeVersionLabel(args.reference) : path.basename(referenceRoot ?? 'reference'); + let candidateGenerated = false; + let referenceGenerated = false; + + if (!referenceRoot && args.reference) { + referenceRoot = path.join(referenceBase, normalizeVersionLabel(args.reference)); + } + + if (args.autoGenerateCandidate) { + console.log(`[layout-snapshots:compare] Refreshing candidate snapshots at ${candidateRoot}...`); + await runCandidateGeneration({ + candidateRoot, + args, + }); + candidateGenerated = true; + if (!(await pathExists(candidateRoot))) { + throw new Error(`Candidate generation completed but folder not found: ${candidateRoot}`); + } + } else if (!(await pathExists(candidateRoot))) { + throw new Error(`Candidate root does not exist: ${candidateRoot}`); + } + + if (!(await pathExists(referenceRoot))) { + if (!args.reference || !args.autoGenerateReference) { + throw new Error(`Reference root does not exist: ${referenceRoot}`); + } + + console.log(`[layout-snapshots:compare] Reference not found at ${referenceRoot}. Generating from npm...`); + const generatedFolder = await runNpmReferenceGeneration({ + referenceSpecifier: args.reference, + args, + }); + referenceGenerated = true; + const resolved = await resolveGeneratedReferenceRoot({ + generatedFolder, + referenceBase, + reference: args.reference, + errorContext: 'Reference generation', + }); + referenceRoot = resolved.root; + resolvedReferenceLabel = resolved.label; + } else if (!args.referenceRoot) { + resolvedReferenceLabel = path.basename(referenceRoot); + } + + let candidateFiles = await listSnapshotFiles(candidateRoot); + let referenceFiles = await listSnapshotFiles(referenceRoot); + let candidatePaths = [...candidateFiles.keys()].sort(); + let referencePaths = [...referenceFiles.keys()].sort(); + + let relation = buildPathRelation(candidatePaths, referencePaths); + + if ( + relation.missingInReference.length > 0 && + args.reference && + args.autoGenerateReference && + !args.referenceRoot + ) { + console.log( + `[layout-snapshots:compare] Reference exists but is incomplete (${relation.missingInReference.length} missing). Regenerating...`, + ); + const generatedFolder = await runNpmReferenceGeneration({ + referenceSpecifier: args.reference, + args, + }); + referenceGenerated = true; + const resolved = await resolveGeneratedReferenceRoot({ + generatedFolder, + referenceBase, + reference: args.reference, + errorContext: 'Reference regeneration', + }); + referenceRoot = resolved.root; + resolvedReferenceLabel = resolved.label; + + referenceFiles = await listSnapshotFiles(referenceRoot); + referencePaths = [...referenceFiles.keys()].sort(); + relation = buildPathRelation(candidatePaths, referencePaths); + } + + const reportsRoot = path.resolve(args.reportsRoot); + const reportDir = args.reportDir + ? path.resolve(args.reportDir) + : path.join(reportsRoot, `${formatTimestamp(new Date())}-${safeLabel(resolvedReferenceLabel)}-vs-candidate`); + + await fs.rm(reportDir, { recursive: true, force: true }).catch(() => {}); + await fs.mkdir(reportDir, { recursive: true }); + await fs.mkdir(path.join(reportDir, 'docs'), { recursive: true }); + + console.log(`[layout-snapshots:compare] Candidate root: ${candidateRoot}`); + console.log(`[layout-snapshots:compare] Reference root: ${referenceRoot}`); + console.log(`[layout-snapshots:compare] Report dir: ${reportDir}`); + + const changedDocs = []; + let unchangedDocCount = 0; + + for (let i = 0; i < relation.matched.length; i += 1) { + const relPath = relation.matched[i]; + const candidateFile = candidateFiles.get(relPath); + const referenceFile = referenceFiles.get(relPath); + + let candidateRaw; + let referenceRaw; + try { + candidateRaw = JSON.parse(await fs.readFile(candidateFile, 'utf8')); + referenceRaw = JSON.parse(await fs.readFile(referenceFile, 'utf8')); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const report = { + path: relPath, + parseError: message, + candidateFile, + referenceFile, + }; + const reportPath = path.join(reportDir, 'docs', `${relPath}.diff.json`); + await fs.mkdir(path.dirname(reportPath), { recursive: true }); + await fs.writeFile(reportPath, JSON.stringify(report, null, 2), 'utf8'); + changedDocs.push({ + path: relPath, + diffCount: 1, + pagesChanged: [], + reportFile: pathToPosix(path.relative(reportDir, reportPath)), + }); + console.log(`[${i + 1}/${relation.matched.length}] CHANGED ${relPath} (parse error)`); + continue; + } + + const candidate = normalizeDocSnapshot(candidateRaw); + const reference = normalizeDocSnapshot(referenceRaw); + const { diffs, truncated } = collectDiffs(reference, candidate, { + numericTolerance: args.numericTolerance, + maxDiffEntries: args.maxDiffEntries, + }); + + if (diffs.length === 0) { + unchangedDocCount += 1; + continue; + } + + const blockPagesMap = getPagesByBlockIndex(candidate); + const grouped = groupDiffsByPage(diffs, blockPagesMap); + const pagesChanged = [...grouped.perPage.keys()].sort((a, b) => a - b); + + const docReport = { + path: relPath, + candidateFile, + referenceFile, + pageCount: { + candidate: Array.isArray(candidate?.layoutSnapshot?.layout?.pages) + ? candidate.layoutSnapshot.layout.pages.length + : 0, + reference: Array.isArray(reference?.layoutSnapshot?.layout?.pages) + ? reference.layoutSnapshot.layout.pages.length + : 0, + }, + diffCount: diffs.length, + truncated, + pagesChanged, + globalDiffs: grouped.global.map((entry) => ({ + path: formatPath(entry.pathSegments), + kind: entry.kind, + reference: summarizeValue(entry.reference), + candidate: summarizeValue(entry.candidate), + ...(typeof entry.delta === 'number' ? { delta: entry.delta } : {}), + })), + pageDiffs: Object.fromEntries( + [...grouped.perPage.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([pageNumber, entries]) => [ + String(pageNumber), + entries.map((entry) => ({ + path: formatPath(entry.pathSegments), + kind: entry.kind, + reference: summarizeValue(entry.reference), + candidate: summarizeValue(entry.candidate), + ...(typeof entry.delta === 'number' ? { delta: entry.delta } : {}), + })), + ]), + ), + }; + + const reportPath = path.join(reportDir, 'docs', `${relPath}.diff.json`); + await fs.mkdir(path.dirname(reportPath), { recursive: true }); + await fs.writeFile(reportPath, JSON.stringify(docReport, null, 2), 'utf8'); + + changedDocs.push({ + path: relPath, + diffCount: diffs.length, + pagesChanged, + reportFile: pathToPosix(path.relative(reportDir, reportPath)), + }); + + console.log( + `[${i + 1}/${relation.matched.length}] CHANGED ${relPath} | diffs ${diffs.length}${pagesChanged.length ? ` | pages ${pagesChanged.join(',')}` : ''}`, + ); + } + + const changedDocPaths = collectChangedDocRelativePaths(changedDocs); + const visualReference = args.visualReference ?? args.reference; + const visualEligible = args.visualOnChange && changedDocPaths.length > 0 && Boolean(visualReference); + + let visualSkipReason = null; + if (!args.visualOnChange) { + visualSkipReason = 'Disabled via --no-visual-on-change.'; + } else if (changedDocPaths.length === 0) { + visualSkipReason = 'No changed docs.'; + } else if (!visualReference) { + visualSkipReason = 'No --reference/--visual-reference provided.'; + } + + let visualComparison = { + enabled: args.visualOnChange, + executed: false, + status: 'skipped', + reason: visualSkipReason, + changedDocCount: changedDocPaths.length, + docs: changedDocPaths, + workdir: null, + docsRoot: null, + reference: null, + error: null, + }; + + if (visualEligible) { + console.log(''); + console.log( + `[layout-snapshots:compare] Changed docs detected (${changedDocPaths.length}). Running visual compare...`, + ); + try { + const visualRun = await runVisualCompareForChangedDocs({ + changedDocPaths, + args, + }); + visualComparison = { + ...visualComparison, + executed: true, + status: 'success', + reason: null, + ...visualRun, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + visualComparison = { + ...visualComparison, + executed: true, + status: 'failed', + reason: message, + error: message, + }; + console.error(`[layout-snapshots:compare] Visual compare failed: ${message}`); + process.exitCode = 1; + } + } + + const summary = { + generatedAt: new Date().toISOString(), + reportDir, + candidateRoot, + referenceRoot, + referenceLabel: resolvedReferenceLabel, + candidateGenerated, + referenceGenerated, + candidateDocCount: candidatePaths.length, + referenceDocCount: referencePaths.length, + matchedDocCount: relation.matched.length, + changedDocCount: changedDocs.length, + unchangedDocCount, + missingInReference: relation.missingInReference, + missingInCandidate: relation.missingInCandidate, + changedDocs, + changedDocPaths, + visualComparison, + }; + + await fs.writeFile(path.join(reportDir, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8'); + await fs.writeFile(path.join(reportDir, 'summary.md'), buildReportMarkdown(summary), 'utf8'); + + console.log(''); + console.log(`[layout-snapshots:compare] Matched docs: ${relation.matched.length}`); + console.log(`[layout-snapshots:compare] Changed docs: ${changedDocs.length}`); + console.log(`[layout-snapshots:compare] Unchanged docs: ${unchangedDocCount}`); + console.log(`[layout-snapshots:compare] Missing reference: ${relation.missingInReference.length}`); + console.log(`[layout-snapshots:compare] Missing candidate: ${relation.missingInCandidate.length}`); + if (visualComparison.executed || visualComparison.enabled) { + console.log(`[layout-snapshots:compare] Visual compare: ${visualComparison.status}`); + } + console.log(`[layout-snapshots:compare] Report: ${reportDir}`); + + const hasDiffs = + changedDocs.length > 0 || relation.missingInReference.length > 0 || relation.missingInCandidate.length > 0; + if (args.failOnDiff && hasDiffs) { + process.exitCode = 1; + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + console.error(`[layout-snapshots:compare] Fatal: ${message}`); + process.exit(1); +}); diff --git a/tests/layout-snapshots/export-layout-snapshots-npm.mjs b/tests/layout-snapshots/export-layout-snapshots-npm.mjs new file mode 100644 index 0000000000..6f50f6cad1 --- /dev/null +++ b/tests/layout-snapshots/export-layout-snapshots-npm.mjs @@ -0,0 +1,297 @@ +#!/usr/bin/env node + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import process from 'node:process'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { normalizeVersionLabel } from './shared.mjs'; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SCRIPT_DIR = path.dirname(SCRIPT_PATH); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '../..'); +const EXPORT_SCRIPT_PATH = path.join(SCRIPT_DIR, 'export-layout-snapshots.mjs'); + +const DEFAULT_INPUT_ROOT = process.env.SUPERDOC_CORPUS_ROOT + ? path.resolve(process.env.SUPERDOC_CORPUS_ROOT) + : path.join(REPO_ROOT, 'test-corpus'); +const DEFAULT_OUTPUT_BASE = path.join(REPO_ROOT, 'tests', 'layout-snapshots', 'reference'); + +function printHelp() { + console.log(` +Usage: + bun tests/layout-snapshots/export-layout-snapshots-npm.mjs [exporter-options] + +Arguments: + npm version/tag (examples: 1.12.0, 1.12.0-next.3, latest) + +Wrapper Options: + --version Same as positional version argument + --installer auto | bun | npm (default: auto) + --output-base Parent folder for versioned snapshots (default: ${DEFAULT_OUTPUT_BASE}) + --keep-temp Keep temporary install directory for debugging + -h, --help Show this help + +All other arguments are forwarded to export-layout-snapshots.mjs. +Common forwarded options include: + --jobs --limit --pipeline --timeout-ms --fail-fast --input-root + +Examples: + bun tests/layout-snapshots/export-layout-snapshots-npm.mjs 1.12.0 --jobs 4 + bun tests/layout-snapshots/export-layout-snapshots-npm.mjs latest --jobs 2 --limit 20 + bun tests/layout-snapshots/export-layout-snapshots-npm.mjs 1.12.0-next.3 --pipeline presentation +`); +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? process.cwd(), + env: options.env ?? process.env, + stdio: options.stdio ?? 'inherit', + }); + + child.on('error', reject); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +function resolveInstaller(preferred) { + const normalized = String(preferred ?? 'auto').toLowerCase(); + if (normalized === 'bun' || normalized === 'npm') return normalized; + if (normalized !== 'auto') { + throw new Error(`Invalid --installer value "${preferred}". Use auto, bun, or npm.`); + } + + const bunCheck = spawnSync('bun', ['--version'], { stdio: 'ignore' }); + if (bunCheck.status === 0) return 'bun'; + + const npmCheck = spawnSync('npm', ['--version'], { stdio: 'ignore' }); + if (npmCheck.status === 0) return 'npm'; + + throw new Error('No supported installer found. Install bun or npm.'); +} + +function hasFlag(args, names) { + return args.some((arg) => names.includes(arg)); +} + +function parseArgs(argv) { + const options = { + version: null, + installer: 'auto', + outputBase: DEFAULT_OUTPUT_BASE, + keepTemp: false, + forwarded: [], + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + if (arg === '--') { + options.forwarded.push(...argv.slice(i + 1)); + break; + } + if (arg === '--version' && next) { + options.version = next; + i += 1; + continue; + } + if (arg === '--installer' && next) { + options.installer = next; + i += 1; + continue; + } + if (arg === '--output-base' && next) { + options.outputBase = next; + i += 1; + continue; + } + if (arg === '--keep-temp') { + options.keepTemp = true; + continue; + } + + // Forward unknown flags (and their value when present) to the exporter. + // This prevents values like "--jobs 4" from being misread as the npm version. + if (arg.startsWith('-')) { + options.forwarded.push(arg); + if (next && !next.startsWith('-')) { + options.forwarded.push(next); + i += 1; + } + continue; + } + + if (!options.version) { + options.version = arg; + continue; + } + + options.forwarded.push(arg); + } + + if (!options.version) { + throw new Error('Missing superdoc version. Provide a version (e.g. 1.12.0 or latest).'); + } + + const reservedFlags = [ + '--module', + '-m', + '--output-root', + '-o', + '--telemetry', + '--enable-telemetry', + '--disable-telemetry', + ]; + if (hasFlag(options.forwarded, reservedFlags)) { + throw new Error( + 'Do not pass --module/--output-root/telemetry flags to this wrapper; they are controlled automatically.', + ); + } + + return options; +} + +async function readInstalledVersion(tempDir) { + const pkgPath = path.join(tempDir, 'node_modules', 'superdoc', 'package.json'); + const raw = await fs.readFile(pkgPath, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed?.version) { + throw new Error(`Installed superdoc package has no version field (${pkgPath}).`); + } + return String(parsed.version); +} + +async function installSuperdoc({ installer, version, tempDir }) { + const packageJsonPath = path.join(tempDir, 'package.json'); + const cacheRoot = path.join(tempDir, '.cache'); + const bunCacheDir = path.join(cacheRoot, 'bun'); + const npmCacheDir = path.join(cacheRoot, 'npm'); + await fs.mkdir(bunCacheDir, { recursive: true }); + await fs.mkdir(npmCacheDir, { recursive: true }); + + const envBase = { + ...process.env, + TMPDIR: tempDir, + TEMP: tempDir, + TMP: tempDir, + }; + + await fs.writeFile( + packageJsonPath, + JSON.stringify( + { + name: 'layout-snapshots-npm-temp', + private: true, + }, + null, + 2, + ), + 'utf8', + ); + + if (installer === 'bun') { + const code = await runCommand('bun', ['add', `superdoc@${version}`], { + cwd: tempDir, + env: { + ...envBase, + BUN_INSTALL_CACHE_DIR: bunCacheDir, + }, + }); + if (code !== 0) { + throw new Error(`bun add failed with exit code ${code}.`); + } + return; + } + + const code = await runCommand( + 'npm', + ['install', '--no-audit', '--no-fund', '--no-package-lock', `superdoc@${version}`], + { + cwd: tempDir, + env: { + ...envBase, + npm_config_cache: npmCacheDir, + }, + }, + ); + if (code !== 0) { + throw new Error(`npm install failed with exit code ${code}.`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const installer = resolveInstaller(options.installer); + + const outputBase = path.resolve(options.outputBase); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'layout-snapshots-npm-')); + let keepTemp = options.keepTemp; + + try { + console.log(`[layout-snapshots:npm] Installer: ${installer}`); + console.log(`[layout-snapshots:npm] Requested version: ${options.version}`); + console.log(`[layout-snapshots:npm] Temp install dir: ${tempDir}`); + + await installSuperdoc({ + installer, + version: options.version, + tempDir, + }); + + const installedVersion = await readInstalledVersion(tempDir); + const versionLabel = normalizeVersionLabel(installedVersion); + const versionOutputRoot = path.join(outputBase, versionLabel); + const modulePath = path.join(tempDir, 'node_modules', 'superdoc', 'dist', 'super-editor.es.js'); + + await fs.access(modulePath); + + console.log(`[layout-snapshots:npm] Resolved version: ${installedVersion}`); + console.log(`[layout-snapshots:npm] Snapshot output root: ${versionOutputRoot}`); + + const forwarded = [...options.forwarded]; + if (!hasFlag(forwarded, ['--input-root', '-i'])) { + forwarded.push('--input-root', DEFAULT_INPUT_ROOT); + } + + const exporterArgs = [ + EXPORT_SCRIPT_PATH, + '--module', + modulePath, + '--output-root', + versionOutputRoot, + '--disable-telemetry', + ...forwarded, + ]; + + const code = await runCommand(process.execPath, exporterArgs); + if (code !== 0) { + throw new Error(`Snapshot export failed with exit code ${code}.`); + } + + console.log(`[layout-snapshots:npm] Done.`); + console.log(`[layout-snapshots:npm] Version folder: ${versionOutputRoot}`); + } catch (error) { + keepTemp = true; + throw error; + } finally { + if (!keepTemp) { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } else { + console.log(`[layout-snapshots:npm] Temp dir kept: ${tempDir}`); + } + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + console.error(`[layout-snapshots:npm] Fatal: ${message}`); + process.exit(1); +}); diff --git a/tests/layout-snapshots/export-layout-snapshots.mjs b/tests/layout-snapshots/export-layout-snapshots.mjs new file mode 100644 index 0000000000..87a16ba01a --- /dev/null +++ b/tests/layout-snapshots/export-layout-snapshots.mjs @@ -0,0 +1,1699 @@ +#!/usr/bin/env node + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import readline from 'node:readline'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import { Window } from 'happy-dom'; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); +const SCRIPT_DIR = path.dirname(SCRIPT_PATH); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '../..'); +const SNAPSHOT_OUTPUT_BASE = path.join(REPO_ROOT, 'tests', 'layout-snapshots'); + +const DEFAULT_INPUT_ROOT = process.env.SUPERDOC_CORPUS_ROOT + ? path.resolve(process.env.SUPERDOC_CORPUS_ROOT) + : path.join(REPO_ROOT, 'test-corpus'); +const DEFAULT_OUTPUT_ROOT = path.join(REPO_ROOT, 'tests', 'layout-snapshots', 'candidate'); +const DEFAULT_SUPER_EDITOR_MODULE = path.resolve(REPO_ROOT, 'packages/superdoc/dist/super-editor.es.js'); +const DEFAULT_PIPELINE = 'headless'; +const HEADER_FOOTER_VARIANTS = ['default', 'first', 'even', 'odd']; +const MAX_LOG_LINE_CHARS = 120; +const TELEMETRY_DISABLED_LOG_FRAGMENT = '[super-editor] Telemetry: disabled'; + +const DEFAULT_PAGE_SIZE = { w: 612, h: 792 }; +const DEFAULT_MARGINS = { top: 72, right: 72, bottom: 72, left: 72 }; +const DEFAULT_PAGE_GAP = 24; +const DEFAULT_HORIZONTAL_PAGE_GAP = 20; + +const require = createRequire(import.meta.url); + +function parseArgs(argv) { + const args = { + inputRoot: DEFAULT_INPUT_ROOT, + outputRoot: DEFAULT_OUTPUT_ROOT, + module: DEFAULT_SUPER_EDITOR_MODULE, + limit: undefined, + timeoutMs: 30_000, + failFast: false, + telemetryEnabled: false, + jobs: 1, + pipeline: DEFAULT_PIPELINE, + + isWorker: false, + workerId: null, + workerManifestPath: null, + totalDocs: null, + summaryFile: null, + suppressFinalSummary: false, + cleanOutput: true, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = argv[i + 1]; + + if ((arg === '--input-root' || arg === '-i') && next) { + args.inputRoot = next; + i += 1; + continue; + } + if ((arg === '--output-root' || arg === '-o') && next) { + args.outputRoot = next; + i += 1; + continue; + } + if ((arg === '--module' || arg === '-m') && next) { + args.module = next; + i += 1; + continue; + } + if (arg === '--limit' && next) { + const parsed = Number(next); + if (Number.isFinite(parsed) && parsed > 0) { + args.limit = Math.floor(parsed); + } + i += 1; + continue; + } + if (arg === '--timeout-ms' && next) { + const parsed = Number(next); + if (Number.isFinite(parsed) && parsed > 0) { + args.timeoutMs = Math.floor(parsed); + } + i += 1; + continue; + } + if (arg === '--jobs' && next) { + const parsed = Number(next); + if (Number.isFinite(parsed) && parsed >= 1) { + args.jobs = Math.floor(parsed); + } else { + throw new Error(`Invalid value for --jobs: "${next}". Expected integer >= 1.`); + } + i += 1; + continue; + } + if (arg === '--pipeline' && next) { + const normalized = String(next).toLowerCase(); + if (normalized === 'headless' || normalized === 'presentation') { + args.pipeline = normalized; + } else { + throw new Error(`Invalid value for --pipeline: "${next}". Use "headless" or "presentation".`); + } + i += 1; + continue; + } + if (arg === '--headless') { + args.pipeline = 'headless'; + continue; + } + if (arg === '--presentation') { + args.pipeline = 'presentation'; + continue; + } + if (arg === '--fail-fast') { + args.failFast = true; + continue; + } + if (arg === '--enable-telemetry') { + args.telemetryEnabled = true; + continue; + } + if (arg === '--disable-telemetry') { + args.telemetryEnabled = false; + continue; + } + if (arg === '--telemetry' && next) { + const normalized = String(next).toLowerCase(); + if (['1', 'true', 'on', 'enabled'].includes(normalized)) { + args.telemetryEnabled = true; + } else if (['0', 'false', 'off', 'disabled'].includes(normalized)) { + args.telemetryEnabled = false; + } else { + throw new Error( + `Invalid value for --telemetry: "${next}". Use one of: on, off, true, false, 1, 0.`, + ); + } + i += 1; + continue; + } + + if (arg === '--worker') { + args.isWorker = true; + continue; + } + if (arg === '--worker-id' && next) { + args.workerId = Number(next); + i += 1; + continue; + } + if (arg === '--worker-manifest' && next) { + args.workerManifestPath = next; + i += 1; + continue; + } + if (arg === '--total-docs' && next) { + args.totalDocs = Number(next); + i += 1; + continue; + } + if (arg === '--summary-file' && next) { + args.summaryFile = next; + i += 1; + continue; + } + if (arg === '--suppress-final-summary') { + args.suppressFinalSummary = true; + continue; + } + if (arg === '--no-clean-output') { + args.cleanOutput = false; + continue; + } + + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + } + + if (args.jobs < 1) { + throw new Error('`--jobs` must be >= 1.'); + } + + return args; +} + +function printHelp() { + console.log(` +Usage: + node tests/layout-snapshots/export-layout-snapshots.mjs [options] + +Options: + -i, --input-root Source DOCX root (default: ${DEFAULT_INPUT_ROOT}) + -o, --output-root Snapshot output root (default: ${DEFAULT_OUTPUT_ROOT}) + -m, --module SuperEditor module (default: ${DEFAULT_SUPER_EDITOR_MODULE}) + --pipeline Layout pipeline: headless | presentation (default: ${DEFAULT_PIPELINE}) + --headless Shorthand for --pipeline headless + --presentation Shorthand for --pipeline presentation + --jobs Process docs with n worker processes (default: 1) + --limit Process at most n DOCX files + --timeout-ms Per-document layout timeout for presentation mode (default: 30000) + --fail-fast Stop on first error + --telemetry Enable/disable editor telemetry (default: off) + --enable-telemetry Shorthand for --telemetry on + --disable-telemetry Shorthand for --telemetry off + -h, --help Show this help + +Examples: + bun tests/layout-snapshots/export-layout-snapshots.mjs + bun tests/layout-snapshots/export-layout-snapshots.mjs --jobs 4 + bun tests/layout-snapshots/export-layout-snapshots.mjs --pipeline presentation --limit 10 + node tests/layout-snapshots/export-layout-snapshots.mjs --module superdoc/super-editor +`); +} + +function resolveModuleSpecifier(value) { + if (!value) { + return pathToFileURL(DEFAULT_SUPER_EDITOR_MODULE).href; + } + if (value.startsWith('.') || value.startsWith('/') || value.startsWith('file:')) { + if (value.startsWith('file:')) return value; + return pathToFileURL(path.resolve(process.cwd(), value)).href; + } + return value; +} + +async function resolveModuleUrl(specifier) { + const normalized = resolveModuleSpecifier(specifier); + if (normalized.startsWith('file:')) { + return normalized; + } + + if (typeof import.meta.resolve === 'function') { + const resolved = import.meta.resolve(normalized); + return typeof resolved === 'string' ? resolved : await resolved; + } + + const resolvedPath = require.resolve(normalized); + return pathToFileURL(resolvedPath).href; +} + +function resolveCanvasConstructor() { + try { + const { Canvas } = require('canvas'); + return { Canvas, usingStub: false }; + } catch { + class MockCanvasRenderingContext2D { + font = ''; + + measureText(text) { + const fontSizeMatch = this.font.match(/([\d.]+)px/); + const fontSize = fontSizeMatch ? Number(fontSizeMatch[1]) : 16; + const bold = /\bbold\b/i.test(this.font); + const italic = /\bitalic\b/i.test(this.font); + const styleMultiplier = (bold ? 1.06 : 1) * (italic ? 1.02 : 1); + const width = text.length * fontSize * 0.5 * styleMultiplier; + return { + width, + actualBoundingBoxAscent: fontSize * 0.8, + actualBoundingBoxDescent: fontSize * 0.2, + }; + } + } + + class MockCanvas { + getContext(type) { + if (type === '2d') return new MockCanvasRenderingContext2D(); + return null; + } + } + + return { Canvas: MockCanvas, usingStub: true }; + } +} + +function installDomEnvironment() { + const window = new Window({ + width: 1280, + height: 720, + url: 'http://localhost', + }); + + globalThis.window = window; + globalThis.document = window.document; + Object.defineProperty(globalThis, 'navigator', { + value: window.navigator, + configurable: true, + writable: true, + }); + globalThis.HTMLElement = window.HTMLElement; + globalThis.Element = window.Element; + globalThis.Node = window.Node; + globalThis.DOMParser = window.DOMParser; + globalThis.MutationObserver = window.MutationObserver; + globalThis.getComputedStyle = window.getComputedStyle.bind(window); + globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window); + globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window); + globalThis.performance = window.performance; + globalThis.ResizeObserver = window.ResizeObserver; + globalThis.DOMRect = window.DOMRect; + globalThis.Range = window.Range; + globalThis.Selection = window.Selection; + globalThis.screen = window.screen; + globalThis.matchMedia = window.matchMedia.bind(window); + + if (typeof globalThis.URL.createObjectURL !== 'function') { + globalThis.URL.createObjectURL = () => 'blob:mock'; + } + if (typeof globalThis.URL.revokeObjectURL !== 'function') { + globalThis.URL.revokeObjectURL = () => {}; + } + + const { Canvas, usingStub } = resolveCanvasConstructor(); + const proto = window.HTMLCanvasElement?.prototype; + if (!proto) { + throw new Error('HTMLCanvasElement is not available in this DOM environment'); + } + const originalGetContext = proto.getContext; + proto.getContext = function getContext(contextId, ...args) { + if (contextId === '2d') { + const w = Number(this.width) > 0 ? Number(this.width) : 1024; + const h = Number(this.height) > 0 ? Number(this.height) : 768; + const nodeCanvas = new Canvas(w, h); + return nodeCanvas.getContext('2d'); + } + if (typeof originalGetContext === 'function') { + return originalGetContext.call(this, contextId, ...args); + } + return null; + }; + + return { usingStubCanvas: usingStub }; +} + +async function listDocxFiles(rootPath) { + const found = []; + const stack = [path.resolve(rootPath)]; + + while (stack.length > 0) { + const current = stack.pop(); + const entries = await fs.readdir(current, { withFileTypes: true }); + + for (const entry of entries) { + const absolutePath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(absolutePath); + continue; + } + if (entry.isFile() && entry.name.toLowerCase().endsWith('.docx')) { + found.push(absolutePath); + } + } + } + + return found.sort(); +} + +function toJsonSafe(value) { + const seen = new WeakSet(); + + const visit = (input) => { + if (input == null) return input; + + const inputType = typeof input; + if (inputType === 'string' || inputType === 'number' || inputType === 'boolean') return input; + if (inputType === 'bigint') return input.toString(); + if (inputType === 'function' || inputType === 'symbol') return undefined; + + if (input instanceof Date) return input.toISOString(); + if (input instanceof Map) { + const out = {}; + for (const [k, v] of input.entries()) { + out[String(k)] = visit(v); + } + return out; + } + if (input instanceof Set) { + return Array.from(input, (item) => visit(item)); + } + if (ArrayBuffer.isView(input)) { + return Array.from(input); + } + if (input instanceof ArrayBuffer) { + return Array.from(new Uint8Array(input)); + } + + if (Array.isArray(input)) { + return input.map((item) => visit(item)); + } + + if (inputType === 'object') { + if (seen.has(input)) { + throw new Error('Encountered circular reference while serializing snapshot'); + } + seen.add(input); + const out = {}; + for (const [k, v] of Object.entries(input)) { + const next = visit(v); + if (next !== undefined) { + out[k] = next; + } + } + seen.delete(input); + return out; + } + + return input; + }; + + return visit(value); +} + +function waitForLayoutUpdate(presentation, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubscribe?.(); + reject(new Error(`Timed out waiting for layout after ${timeoutMs}ms`)); + }, timeoutMs); + + const unsubscribe = presentation.onLayoutUpdated((payload) => { + clearTimeout(timer); + unsubscribe?.(); + resolve(payload); + }); + }); +} + +function makeOutputPath(outputRoot, inputRoot, docxPath) { + const relativePath = path.relative(inputRoot, docxPath); + const outRelativePath = `${relativePath}.layout.json`; + return { + relativePath, + outRelativePath, + outputPath: path.join(outputRoot, outRelativePath), + }; +} + +function assertSafeOutputRoot(outputRoot) { + const normalized = path.resolve(outputRoot); + const parsed = path.parse(normalized); + + if (normalized === parsed.root) { + throw new Error(`Refusing to wipe filesystem root: ${normalized}`); + } + + if (normalized === REPO_ROOT) { + throw new Error(`Refusing to wipe repository root: ${normalized}`); + } + + const relativeToSnapshotBase = path.relative(SNAPSHOT_OUTPUT_BASE, normalized); + const isWithinSnapshotBase = + Boolean(relativeToSnapshotBase) && + relativeToSnapshotBase !== '.' && + !relativeToSnapshotBase.startsWith('..') && + !path.isAbsolute(relativeToSnapshotBase); + + if (!isWithinSnapshotBase) { + throw new Error(`Refusing to wipe unsafe output path: ${normalized}`); + } +} + +function nowMs() { + return Number(process.hrtime.bigint()) / 1_000_000; +} + +function formatDuration(ms) { + return `${(ms / 1000).toFixed(2)}s`; +} + +function wrapText(text, maxChars = MAX_LOG_LINE_CHARS) { + const normalized = String(text ?? ''); + if (normalized.length <= maxChars) return [normalized]; + + const lines = []; + let remaining = normalized; + + while (remaining.length > maxChars) { + let splitAt = remaining.lastIndexOf(' ', maxChars); + if (splitAt <= 0) { + splitAt = maxChars; + } + lines.push(remaining.slice(0, splitAt).trimEnd()); + remaining = remaining.slice(splitAt).trimStart(); + } + + if (remaining.length > 0) { + lines.push(remaining); + } + + return lines; +} + +function logLine(text) { + const lines = wrapText(text); + for (const line of lines) { + console.log(line); + } +} + +function errorLine(text) { + const lines = wrapText(text); + for (const line of lines) { + console.error(line); + } +} + +function shouldSuppressTelemetryDisabledLog(line, telemetryEnabled) { + return ( + !telemetryEnabled && + typeof line === 'string' && + line.includes(TELEMETRY_DISABLED_LOG_FRAGMENT) + ); +} + +function installTelemetryConsoleFilter(telemetryEnabled) { + if (telemetryEnabled) { + return () => {}; + } + + const methods = ['debug', 'log', 'info', 'warn']; + const originals = new Map(); + + for (const method of methods) { + const original = console[method]; + if (typeof original !== 'function') continue; + + originals.set(method, original); + console[method] = (...parts) => { + const hasSuppressedLine = parts.some( + (part) => typeof part === 'string' && part.includes(TELEMETRY_DISABLED_LOG_FRAGMENT), + ); + if (hasSuppressedLine) return; + original.apply(console, parts); + }; + } + + return () => { + for (const [method, original] of originals.entries()) { + console[method] = original; + } + }; +} + +function logDocProgress({ progress, relativePath, pageCount, docElapsedMs, phaseLabel }) { + logLine(`${progress} OK ${relativePath}`); + logLine(` pages: ${pageCount} | took ${formatDuration(docElapsedMs)}`); + logLine(` phases: ${phaseLabel}`); +} + +function logDocFailure({ progress, relativePath, docElapsedMs, message }) { + logLine(`${progress} FAIL ${relativePath}`); + logLine(` took: ${formatDuration(docElapsedMs)}`); + errorLine(` error: ${message}`); +} + +function inchesToPx(value) { + if (value == null) return undefined; + const num = Number(value); + if (!Number.isFinite(num)) return undefined; + return num * 96; +} + +function parseColumns(raw) { + if (!raw || typeof raw !== 'object') return undefined; + const source = raw; + const rawCount = Number(source.count ?? source.num ?? source.numberOfColumns ?? 1); + if (!Number.isFinite(rawCount) || rawCount <= 1) return undefined; + const count = Math.max(1, Math.floor(rawCount)); + const gap = inchesToPx(source.space ?? source.gap) ?? 0; + return { count, gap }; +} + +function computeDefaultLayoutDefaults(converter) { + const pageStyles = converter?.pageStyles ?? {}; + const size = pageStyles.pageSize ?? {}; + const pageMargins = pageStyles.pageMargins ?? {}; + + const pageSize = { + w: inchesToPx(size.width) ?? DEFAULT_PAGE_SIZE.w, + h: inchesToPx(size.height) ?? DEFAULT_PAGE_SIZE.h, + }; + + const margins = { + top: inchesToPx(pageMargins.top) ?? DEFAULT_MARGINS.top, + right: inchesToPx(pageMargins.right) ?? DEFAULT_MARGINS.right, + bottom: inchesToPx(pageMargins.bottom) ?? DEFAULT_MARGINS.bottom, + left: inchesToPx(pageMargins.left) ?? DEFAULT_MARGINS.left, + ...(inchesToPx(pageMargins.header) != null ? { header: inchesToPx(pageMargins.header) } : {}), + ...(inchesToPx(pageMargins.footer) != null ? { footer: inchesToPx(pageMargins.footer) } : {}), + }; + + const columns = parseColumns(pageStyles.columns); + return { pageSize, margins, columns }; +} + +function resolveLayoutOptions({ defaults, blocks, sectionMetadata }) { + const firstSection = blocks?.find( + (block) => block.kind === 'sectionBreak' && block?.attrs?.isFirstSection, + ); + + const pageSize = firstSection?.pageSize ?? defaults.pageSize; + const margins = { + ...defaults.margins, + ...(firstSection?.margins?.top != null ? { top: firstSection.margins.top } : {}), + ...(firstSection?.margins?.right != null ? { right: firstSection.margins.right } : {}), + ...(firstSection?.margins?.bottom != null ? { bottom: firstSection.margins.bottom } : {}), + ...(firstSection?.margins?.left != null ? { left: firstSection.margins.left } : {}), + ...(firstSection?.margins?.header != null ? { header: firstSection.margins.header } : {}), + ...(firstSection?.margins?.footer != null ? { footer: firstSection.margins.footer } : {}), + }; + const columns = firstSection?.columns ?? defaults.columns; + + return { + pageSize, + margins, + ...(columns ? { columns } : {}), + sectionMetadata, + }; +} + +function computeHeaderFooterConstraints(layoutOptions) { + const pageSize = layoutOptions.pageSize ?? DEFAULT_PAGE_SIZE; + const margins = layoutOptions.margins ?? DEFAULT_MARGINS; + const marginLeft = margins.left ?? DEFAULT_MARGINS.left; + const marginRight = margins.right ?? DEFAULT_MARGINS.right; + const bodyContentWidth = pageSize.w - (marginLeft + marginRight); + if (!Number.isFinite(bodyContentWidth) || bodyContentWidth <= 0) return null; + + const marginTop = margins.top ?? DEFAULT_MARGINS.top; + const marginBottom = margins.bottom ?? DEFAULT_MARGINS.bottom; + if (!Number.isFinite(marginTop) || !Number.isFinite(marginBottom)) return null; + if (marginTop + marginBottom >= pageSize.h) return null; + + const MIN_HEADER_FOOTER_HEIGHT = 1; + const height = Math.max(MIN_HEADER_FOOTER_HEIGHT, pageSize.h - (marginTop + marginBottom)); + const headerMargin = margins.header ?? 0; + const footerMargin = margins.footer ?? 0; + const headerBand = Math.max(MIN_HEADER_FOOTER_HEIGHT, marginTop - headerMargin); + const footerBand = Math.max(MIN_HEADER_FOOTER_HEIGHT, marginBottom - footerMargin); + const overflowBaseHeight = Math.max(headerBand, footerBand); + + return { + width: bodyContentWidth, + height, + pageWidth: pageSize.w, + margins: { left: marginLeft, right: marginRight }, + overflowBaseHeight, + }; +} + +function getEffectivePageGap(layoutOptions) { + if (layoutOptions?.virtualization?.enabled) { + return Math.max(0, layoutOptions.virtualization.gap ?? DEFAULT_PAGE_GAP); + } + if (layoutOptions?.layoutMode === 'horizontal') { + return DEFAULT_HORIZONTAL_PAGE_GAP; + } + return DEFAULT_PAGE_GAP; +} + +function buildFootnoteNumberById(editor) { + const footnoteNumberById = {}; + try { + const seen = new Set(); + let counter = 1; + editor?.state?.doc?.descendants?.((node) => { + if (node?.type?.name !== 'footnoteReference') return; + const rawId = node?.attrs?.id; + if (rawId == null) return; + const key = String(rawId); + if (!key || seen.has(key)) return; + seen.add(key); + footnoteNumberById[key] = counter; + counter += 1; + }); + } catch { + // Best effort: if traversal fails, fall back to no numbering map. + } + return footnoteNumberById; +} + +function buildConverterContext(converter, footnoteNumberById) { + if (!converter) return undefined; + return { + docx: converter.convertedXml, + ...(Object.keys(footnoteNumberById).length > 0 ? { footnoteNumberById } : {}), + translatedLinkedStyles: converter.translatedLinkedStyles, + translatedNumbering: converter.translatedNumbering, + }; +} + +function buildHeaderFooterConverterContext(converter) { + if (!converter) return undefined; + return { + docx: converter.convertedXml, + numbering: converter.numbering, + translatedLinkedStyles: converter.translatedLinkedStyles, + translatedNumbering: converter.translatedNumbering, + }; +} + +function toHeaderFooterDoc(doc) { + if (!doc || typeof doc !== 'object') return null; + if (doc.type === 'doc') return doc; + if (Array.isArray(doc.content)) { + return { type: 'doc', content: doc.content }; + } + return null; +} + +function collectHeaderFooterIds(idConfig, docsById) { + const ids = new Set(); + + for (const variant of HEADER_FOOTER_VARIANTS) { + const value = idConfig?.[variant]; + if (typeof value === 'string' && value.length > 0) { + ids.add(value); + } + } + + const arrayIds = idConfig?.ids; + if (Array.isArray(arrayIds)) { + for (const value of arrayIds) { + if (typeof value === 'string' && value.length > 0) { + ids.add(value); + } + } + } + + if (docsById && typeof docsById === 'object') { + for (const key of Object.keys(docsById)) { + if (typeof key === 'string' && key.length > 0) { + ids.add(key); + } + } + } + + return ids; +} + +function buildHeaderFooterInput({ toFlowBlocks, converter, converterContext, atomNodeTypes, layoutOptions, mediaFiles }) { + const headers = converter?.headers ?? {}; + const footers = converter?.footers ?? {}; + const headerIds = converter?.headerIds ?? {}; + const footerIds = converter?.footerIds ?? {}; + + const docDefaults = converter?.getDocumentDefaultStyles?.(); + const defaultFont = docDefaults?.typeface; + const defaultSize = docDefaults?.fontSizePt != null ? docDefaults.fontSizePt * (96 / 72) : undefined; + + const createResolver = (kind, docsById, idConfig) => { + const cache = new Map(); + const getBlocksById = (rId) => { + if (cache.has(rId)) return cache.get(rId); + const doc = toHeaderFooterDoc(docsById?.[rId]); + if (!doc) { + cache.set(rId, undefined); + return undefined; + } + const blockIdPrefix = `hf-${kind}-${rId}-`; + const result = toFlowBlocks(doc, { + mediaFiles, + blockIdPrefix, + converterContext, + defaultFont, + defaultSize, + ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), + }); + const blocks = Array.isArray(result?.blocks) && result.blocks.length > 0 ? result.blocks : undefined; + cache.set(rId, blocks); + return blocks; + }; + + const batch = {}; + for (const variant of HEADER_FOOTER_VARIANTS) { + const rId = idConfig?.[variant]; + if (typeof rId !== 'string' || !rId) continue; + const blocks = getBlocksById(rId); + if (blocks?.length) { + batch[variant] = blocks; + } + } + + const allIds = collectHeaderFooterIds(idConfig, docsById); + const byRId = new Map(); + for (const rId of allIds) { + const blocks = getBlocksById(rId); + if (blocks?.length) { + byRId.set(rId, blocks); + } + } + + return { + batch: Object.keys(batch).length > 0 ? batch : undefined, + byRId: byRId.size > 0 ? byRId : undefined, + }; + }; + + const header = createResolver('header', headers, headerIds); + const footer = createResolver('footer', footers, footerIds); + const constraints = computeHeaderFooterConstraints(layoutOptions); + + if (!constraints) return null; + if (!header.batch && !footer.batch && !header.byRId && !footer.byRId) return null; + + return { + headerBlocks: header.batch, + footerBlocks: footer.batch, + headerBlocksByRId: header.byRId, + footerBlocksByRId: footer.byRId, + constraints, + }; +} + +function parseNamedImports(importList) { + const mappings = new Map(); + const chunks = importList + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + for (const chunk of chunks) { + const match = chunk.match(/^([A-Za-z0-9_$]+)\s+as\s+([A-Za-z0-9_$]+)$/); + if (match) { + const [, importedName, localName] = match; + mappings.set(localName, importedName); + continue; + } + + if (/^[A-Za-z0-9_$]+$/.test(chunk)) { + mappings.set(chunk, chunk); + } + } + + return mappings; +} + +function parseNamedExports(moduleSource) { + const mappings = new Map(); + const exportPattern = /export\s*\{([\s\S]*?)\}\s*;/g; + + for (const match of moduleSource.matchAll(exportPattern)) { + const exportList = match[1]; + const entries = exportList + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + for (const entry of entries) { + const aliasMatch = entry.match(/^([A-Za-z0-9_$]+)\s+as\s+([A-Za-z0-9_$]+)$/); + if (aliasMatch) { + const [, localName, publicName] = aliasMatch; + mappings.set(publicName, localName); + continue; + } + + if (/^[A-Za-z0-9_$]+$/.test(entry)) { + mappings.set(entry, entry); + } + } + } + + return mappings; +} + +async function resolveRuntimeChunkInfo(superEditorModulePath) { + const moduleSource = await fs.readFile(superEditorModulePath, 'utf8'); + const importPattern = /import\s*\{([\s\S]*?)\}\s*from\s*['"](\.\/chunks\/[^'"]+\.es\.js)['"];/g; + const publicToLocalExports = parseNamedExports(moduleSource); + const localEditorName = publicToLocalExports.get('Editor') ?? 'Editor'; + const localStarterName = publicToLocalExports.get('getStarterExtensions') ?? 'getStarterExtensions'; + const localPresentationName = publicToLocalExports.get('PresentationEditor') ?? 'PresentationEditor'; + + for (const match of moduleSource.matchAll(importPattern)) { + const [, importList, chunkSpec] = match; + const imports = parseNamedImports(importList); + + const editorExportName = imports.get(localEditorName); + const starterExportName = imports.get(localStarterName); + const presentationExportName = imports.get(localPresentationName) ?? null; + + if (!editorExportName || !starterExportName) { + continue; + } + + const chunkPath = path.resolve(path.dirname(superEditorModulePath), chunkSpec); + const chunkSource = await fs.readFile(chunkPath, 'utf8'); + + return { + chunkPath, + chunkSource, + exportNames: { + Editor: editorExportName, + getStarterExtensions: starterExportName, + PresentationEditor: presentationExportName, + }, + }; + } + + throw new Error( + `Unable to resolve Editor/getStarterExtensions import mapping from module: ${superEditorModulePath}`, + ); +} + +function rewriteRelativeImports(source, chunkDir) { + return source.replace( + /(from\s+['"]|import\(\s*['"])(\.\.?\/[^'"]+)(['"])/g, + (match, prefix, relPath, suffix) => { + const absUrl = pathToFileURL(path.resolve(chunkDir, relPath)).href; + return `${prefix}${absUrl}${suffix}`; + }, + ); +} + +async function loadRuntimeModule(moduleUrl, { requireHeadlessPrimitives }) { + const modulePath = fileURLToPath(moduleUrl); + const runtimeInfo = await resolveRuntimeChunkInfo(modulePath); + const chunkDir = path.dirname(runtimeInfo.chunkPath); + const tempModulePath = path.join( + chunkDir, + `.layout-snapshot-runtime-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.mjs`, + ); + const rewrittenSource = rewriteRelativeImports(runtimeInfo.chunkSource, chunkDir); + const exportLine = + '\nexport { toFlowBlocks, FlowBlockCache, buildFootnotesInput, getAtomNodeTypes, buildPositionMapFromPmDoc, incrementalLayout, measureBlock };\n'; + await fs.writeFile(tempModulePath, `${rewrittenSource}${exportLine}`, 'utf8'); + + try { + const runtimeModule = await import(pathToFileURL(tempModulePath).href); + const Editor = runtimeModule[runtimeInfo.exportNames.Editor]; + const getStarterExtensions = runtimeModule[runtimeInfo.exportNames.getStarterExtensions]; + const PresentationEditor = runtimeInfo.exportNames.PresentationEditor + ? runtimeModule[runtimeInfo.exportNames.PresentationEditor] + : undefined; + + if (!Editor || !getStarterExtensions) { + throw new Error('Failed to resolve Editor/getStarterExtensions from extracted runtime module.'); + } + + const headlessPrimitives = { + toFlowBlocks: runtimeModule.toFlowBlocks, + FlowBlockCache: runtimeModule.FlowBlockCache, + buildFootnotesInput: runtimeModule.buildFootnotesInput, + getAtomNodeTypes: runtimeModule.getAtomNodeTypes, + buildPositionMapFromPmDoc: runtimeModule.buildPositionMapFromPmDoc, + incrementalLayout: runtimeModule.incrementalLayout, + measureBlock: runtimeModule.measureBlock, + }; + + if (requireHeadlessPrimitives) { + for (const [name, value] of Object.entries(headlessPrimitives)) { + if (typeof value === 'undefined') { + throw new Error(`Headless primitive "${name}" not found in extracted runtime module.`); + } + } + } + + return { + moduleExports: { + Editor, + PresentationEditor, + getStarterExtensions, + }, + headlessPrimitives: requireHeadlessPrimitives ? headlessPrimitives : null, + }; + } finally { + await fs.rm(tempModulePath, { force: true }).catch(() => {}); + } +} + +function writeWithBackpressure(writer, chunk) { + return new Promise((resolve) => { + const ok = writer.write(chunk); + if (ok) { + resolve(); + return; + } + writer.once('drain', () => resolve()); + }); +} + +function createSerialLineWriter(writer) { + let queue = Promise.resolve(); + return { + push(line) { + queue = queue.then(() => writeWithBackpressure(writer, `${line}\n`)); + return queue; + }, + flush() { + return queue; + }, + }; +} + +function pipeWithPrefix(stream, prefix, lineWriter, { suppressLine } = {}) { + if (!stream) return Promise.resolve(); + + return (async () => { + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + try { + for await (const line of rl) { + if (suppressLine?.(line)) continue; + const wrapped = wrapText(`${prefix}${line}`); + for (const wrappedLine of wrapped) { + await lineWriter.push(wrappedLine); + } + } + } catch { + // Best effort forwarding: ignore stream forwarding errors and let worker exit code decide failure. + } + })(); +} + +function chunkDocEntries(entries, jobs) { + const chunks = Array.from({ length: jobs }, () => []); + entries.forEach((entry, index) => { + chunks[index % jobs].push(entry); + }); + return chunks; +} + +async function runWorkers({ args, moduleUrl, docs, totalDocs, inputRoot, outputRoot }) { + const runTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'layout-snapshots-')); + const chunks = chunkDocEntries(docs, args.jobs).filter((chunk) => chunk.length > 0); + const workerLogWriter = createSerialLineWriter(process.stdout); + + const workerSpecs = []; + for (let i = 0; i < chunks.length; i += 1) { + const workerId = i + 1; + const manifestPath = path.join(runTempDir, `worker-${workerId}.manifest.json`); + const summaryPath = path.join(runTempDir, `worker-${workerId}.summary.json`); + + await fs.writeFile( + manifestPath, + JSON.stringify({ + docs: chunks[i], + }), + 'utf8', + ); + + workerSpecs.push({ workerId, manifestPath, summaryPath, docCount: chunks[i].length }); + } + + const runtime = process.execPath; + + const runWorker = (spec) => + new Promise((resolve) => { + const workerArgs = [ + SCRIPT_PATH, + '--worker', + '--worker-id', + String(spec.workerId), + '--worker-manifest', + spec.manifestPath, + '--total-docs', + String(totalDocs), + '--summary-file', + spec.summaryPath, + '--input-root', + inputRoot, + '--output-root', + outputRoot, + '--module', + moduleUrl, + '--pipeline', + args.pipeline, + '--jobs', + '1', + '--timeout-ms', + String(args.timeoutMs), + '--no-clean-output', + '--suppress-final-summary', + ]; + + if (args.failFast) workerArgs.push('--fail-fast'); + if (args.telemetryEnabled) workerArgs.push('--enable-telemetry'); + else workerArgs.push('--disable-telemetry'); + + const child = spawn(runtime, workerArgs, { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const suppressLine = (line) => shouldSuppressTelemetryDisabledLog(line, args.telemetryEnabled); + const stdoutDone = pipeWithPrefix(child.stdout, `[w${spec.workerId}] `, workerLogWriter, { suppressLine }); + const stderrDone = pipeWithPrefix(child.stderr, `[w${spec.workerId}] `, workerLogWriter, { suppressLine }); + + child.on('close', (code) => { + Promise.all([stdoutDone, stderrDone]).then(() => { + resolve({ spec, code: code ?? 1 }); + }); + }); + child.on('error', () => { + Promise.all([stdoutDone, stderrDone]).then(() => { + resolve({ spec, code: 1 }); + }); + }); + }); + + const results = await Promise.all(workerSpecs.map((spec) => runWorker(spec))); + await workerLogWriter.flush(); + + const summaries = []; + for (const result of results) { + try { + const raw = await fs.readFile(result.spec.summaryPath, 'utf8'); + const parsed = JSON.parse(raw); + summaries.push(parsed); + } catch { + summaries.push({ + elapsedMs: 0, + successCount: 0, + failures: [ + { + path: `worker-${result.spec.workerId}`, + message: `Worker exited with code ${result.code} and did not produce summary output.`, + }, + ], + phaseTotals: { + importMs: 0, + editorInitMs: 0, + layoutWaitMs: 0, + layoutMs: 0, + serializeMs: 0, + writeMs: 0, + totalMs: 0, + }, + }); + } + } + + await fs.rm(runTempDir, { recursive: true, force: true }).catch(() => {}); + + const merged = { + elapsedMs: summaries.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0), + successCount: summaries.reduce((sum, s) => sum + (s.successCount ?? 0), 0), + failures: summaries.flatMap((s) => s.failures ?? []), + phaseTotals: { + importMs: summaries.reduce((sum, s) => sum + (s.phaseTotals?.importMs ?? 0), 0), + editorInitMs: summaries.reduce((sum, s) => sum + (s.phaseTotals?.editorInitMs ?? 0), 0), + layoutWaitMs: summaries.reduce((sum, s) => sum + (s.phaseTotals?.layoutWaitMs ?? 0), 0), + layoutMs: summaries.reduce((sum, s) => sum + (s.phaseTotals?.layoutMs ?? 0), 0), + serializeMs: summaries.reduce((sum, s) => sum + (s.phaseTotals?.serializeMs ?? 0), 0), + writeMs: summaries.reduce((sum, s) => sum + (s.phaseTotals?.writeMs ?? 0), 0), + totalMs: summaries.reduce((sum, s) => sum + (s.phaseTotals?.totalMs ?? 0), 0), + }, + }; + + return merged; +} + +function printFinalSummary({ elapsedMs, successCount, failures, phaseTotals, outputRoot }) { + console.log(''); + logLine(`[layout-snapshots] Success: ${successCount}`); + logLine(`[layout-snapshots] Failed: ${failures.length}`); + logLine(`[layout-snapshots] Snapshot output directory: ${outputRoot}`); + logLine(`[layout-snapshots] Snapshot files written: ${successCount}`); + + if (successCount > 0) { + const avgMs = phaseTotals.totalMs / successCount; + const pct = (value) => (phaseTotals.totalMs > 0 ? ((value / phaseTotals.totalMs) * 100).toFixed(1) : '0.0'); + logLine(`[layout-snapshots] Avg/doc: ${formatDuration(avgMs)}`); + logLine( + `[layout-snapshots] Phase totals: import ${formatDuration(phaseTotals.importMs)} (${pct(phaseTotals.importMs)}%), editorInit ${formatDuration(phaseTotals.editorInitMs)} (${pct(phaseTotals.editorInitMs)}%), waitLayout ${formatDuration(phaseTotals.layoutWaitMs)} (${pct(phaseTotals.layoutWaitMs)}%), layoutTotal ${formatDuration(phaseTotals.layoutMs)} (${pct(phaseTotals.layoutMs)}%), serialize ${formatDuration(phaseTotals.serializeMs)} (${pct(phaseTotals.serializeMs)}%), write ${formatDuration(phaseTotals.writeMs)} (${pct(phaseTotals.writeMs)}%)`, + ); + + } + + if (failures.length > 0) { + for (const failure of failures) { + const elapsed = typeof failure.elapsedMs === 'number' ? ` after ${formatDuration(failure.elapsedMs)}` : ''; + logLine(`- ${failure.path}${elapsed}: ${failure.message}`); + } + } + + logLine(`[layout-snapshots] Complete in ${(elapsedMs / 1000).toFixed(2)}s`); +} + +async function renderWithPresentation({ + Editor, + PresentationEditor, + getStarterExtensions, + args, + docxPath, + docId, + relativePath, +}) { + let presentation = null; + let host = null; + + let importMs = 0; + let editorInitMs = 0; + let layoutWaitMs = 0; + let layoutMs = 0; + + try { + const importStartedAtMs = nowMs(); + const docxBuffer = await fs.readFile(docxPath); + const loaded = await Editor.loadXmlData(docxBuffer, true); + if (!Array.isArray(loaded) || loaded.length < 4) { + throw new Error('Editor.loadXmlData returned invalid data'); + } + importMs = nowMs() - importStartedAtMs; + + const [content, media, mediaFiles, fonts] = loaded; + + host = document.createElement('div'); + document.body.appendChild(host); + + const layoutStartedAtMs = nowMs(); + const editorInitStartedAtMs = nowMs(); + presentation = new PresentationEditor({ + element: host, + documentId: docId, + mode: 'docx', + telemetry: { enabled: args.telemetryEnabled }, + extensions: getStarterExtensions(), + content, + media, + mediaFiles, + fonts, + layoutEngineOptions: { + virtualization: { enabled: false }, + }, + }); + editorInitMs = nowMs() - editorInitStartedAtMs; + + const layoutWaitStartedAtMs = nowMs(); + const payload = await waitForLayoutUpdate(presentation, args.timeoutMs); + layoutWaitMs = nowMs() - layoutWaitStartedAtMs; + layoutMs = nowMs() - layoutStartedAtMs; + + const snapshot = presentation.getLayoutSnapshot(); + const layoutOptions = presentation.getLayoutOptions(); + const pageCount = snapshot.layout?.pages?.length ?? 0; + + return { + snapshot, + layoutOptions, + metrics: payload.metrics, + pageCount, + timings: { + importMs, + editorInitMs, + layoutWaitMs, + layoutMs, + }, + pipelineRuntime: { + mode: 'presentation', + }, + }; + } finally { + try { + presentation?.destroy?.(); + } catch {} + try { + host?.remove?.(); + } catch {} + } +} + +async function renderWithHeadless({ + Editor, + getStarterExtensions, + headlessPrimitives, + args, + docxPath, + docId, + relativePath, +}) { + let editor = null; + + let importMs = 0; + let editorInitMs = 0; + let layoutMs = 0; + + try { + const importStartedAtMs = nowMs(); + const docxBuffer = await fs.readFile(docxPath); + const loaded = await Editor.loadXmlData(docxBuffer, true); + if (!Array.isArray(loaded) || loaded.length < 4) { + throw new Error('Editor.loadXmlData returned invalid data'); + } + importMs = nowMs() - importStartedAtMs; + + const [content, media, mediaFiles, fonts] = loaded; + + const editorInitStartedAtMs = nowMs(); + editor = new Editor({ + documentId: docId, + mode: 'docx', + isHeadless: true, + telemetry: { enabled: args.telemetryEnabled }, + extensions: getStarterExtensions(), + content, + media, + mediaFiles, + fonts, + }); + editorInitMs = nowMs() - editorInitStartedAtMs; + + const layoutStartedAtMs = nowMs(); + + const docJson = editor.getJSON?.() ?? editor.state?.doc?.toJSON?.(); + if (!docJson || typeof docJson !== 'object') { + throw new Error('Failed to serialize editor document JSON for headless layout'); + } + + const sectionMetadata = []; + const footnoteNumberById = buildFootnoteNumberById(editor); + const converterContext = buildConverterContext(editor.converter, footnoteNumberById); + const atomNodeTypes = headlessPrimitives.getAtomNodeTypes(editor?.schema ?? null); + const positionMap = + editor?.state?.doc && docJson ? headlessPrimitives.buildPositionMapFromPmDoc(editor.state.doc, docJson) : null; + + const flowBlockCache = new headlessPrimitives.FlowBlockCache(); + const toFlowBlocksStart = nowMs(); + const toFlowResult = headlessPrimitives.toFlowBlocks(docJson, { + mediaFiles: editor?.storage?.image?.media, + emitSectionBreaks: true, + sectionMetadata, + trackedChangesMode: 'review', + enableTrackedChanges: true, + enableComments: true, + enableRichHyperlinks: true, + themeColors: editor?.converter?.themeColors ?? undefined, + converterContext, + flowBlockCache, + ...(positionMap ? { positions: positionMap } : {}), + ...(atomNodeTypes.length > 0 ? { atomNodeTypes } : {}), + }); + const toFlowBlocksMs = nowMs() - toFlowBlocksStart; + + const blocks = toFlowResult?.blocks; + if (!Array.isArray(blocks)) { + throw new Error('toFlowBlocks returned invalid blocks in headless mode'); + } + + const defaults = computeDefaultLayoutDefaults(editor.converter); + const baseLayoutOptions = resolveLayoutOptions({ defaults, blocks, sectionMetadata }); + + const footnotesLayoutInput = headlessPrimitives.buildFootnotesInput( + editor?.state, + editor?.converter, + converterContext, + editor?.converter?.themeColors ?? undefined, + ); + + const layoutOptions = footnotesLayoutInput ? { ...baseLayoutOptions, footnotes: footnotesLayoutInput } : baseLayoutOptions; + + const headerFooterInput = buildHeaderFooterInput({ + toFlowBlocks: headlessPrimitives.toFlowBlocks, + converter: editor?.converter, + converterContext: buildHeaderFooterConverterContext(editor?.converter), + atomNodeTypes, + layoutOptions, + mediaFiles: + (editor?.storage?.image?.media && Object.keys(editor.storage.image.media).length > 0 + ? editor.storage.image.media + : editor?.converter?.media) ?? undefined, + }); + + const incrementalLayoutStart = nowMs(); + const layoutResult = await headlessPrimitives.incrementalLayout( + [], + null, + blocks, + layoutOptions, + (block, constraints) => headlessPrimitives.measureBlock(block, constraints), + headerFooterInput ?? undefined, + null, + ); + const incrementalLayoutMs = nowMs() - incrementalLayoutStart; + + if (!layoutResult?.layout || !Array.isArray(layoutResult?.measures)) { + throw new Error('incrementalLayout returned invalid result in headless mode'); + } + + const layout = layoutResult.layout; + layout.pageGap = getEffectivePageGap({ virtualization: { enabled: false }, layoutMode: 'vertical' }); + + layoutMs = nowMs() - layoutStartedAtMs; + + const snapshot = { + layout, + blocks, + measures: layoutResult.measures, + sectionMetadata, + }; + + const pageCount = snapshot.layout?.pages?.length ?? 0; + + return { + snapshot, + layoutOptions: { + pageSize: layoutOptions.pageSize, + margins: layoutOptions.margins, + ...(layoutOptions.columns ? { columns: layoutOptions.columns } : {}), + virtualization: { enabled: false }, + zoom: 1, + layoutMode: 'vertical', + }, + metrics: { + toFlowBlocksMs, + incrementalLayoutMs, + layoutMs, + }, + pageCount, + timings: { + importMs, + editorInitMs, + layoutWaitMs: 0, + layoutMs, + }, + pipelineRuntime: { + mode: 'headless', + toFlowBlocksMs, + incrementalLayoutMs, + }, + }; + } finally { + try { + editor?.destroy?.(); + } catch {} + } +} + +async function runDocBatch({ + args, + inputRoot, + outputRoot, + moduleUrl, + envInfo, + moduleExports, + headlessPrimitives, + docEntries, + totalDocs, +}) { + const { Editor, PresentationEditor, getStarterExtensions } = moduleExports; + + if (!Editor || !getStarterExtensions) { + throw new Error(`Module "${args.module}" must export Editor and getStarterExtensions.`); + } + if (args.pipeline === 'presentation' && !PresentationEditor) { + throw new Error(`Module "${args.module}" must export PresentationEditor for pipeline=presentation.`); + } + + const startedAt = Date.now(); + let successCount = 0; + const failures = []; + const phaseTotals = { + importMs: 0, + editorInitMs: 0, + layoutWaitMs: 0, + layoutMs: 0, + serializeMs: 0, + writeMs: 0, + totalMs: 0, + }; + + for (let localIndex = 0; localIndex < docEntries.length; localIndex += 1) { + const entry = docEntries[localIndex]; + const docxPath = typeof entry === 'string' ? entry : entry.path; + const globalIndex = typeof entry === 'object' && typeof entry.index === 'number' ? entry.index : localIndex + 1; + + const { relativePath, outputPath } = makeOutputPath(outputRoot, inputRoot, docxPath); + const progress = `[${globalIndex}/${totalDocs}]`; + + const docStartedAtMs = nowMs(); + + let importMs = 0; + let editorInitMs = 0; + let layoutWaitMs = 0; + let layoutMs = 0; + let serializeMs = 0; + let writeMs = 0; + + try { + const docId = `layout-snapshot-${globalIndex}-${relativePath}`; + const rendered = + args.pipeline === 'presentation' + ? await renderWithPresentation({ + Editor, + PresentationEditor, + getStarterExtensions, + args, + docxPath, + docId, + relativePath, + }) + : await renderWithHeadless({ + Editor, + getStarterExtensions, + headlessPrimitives, + args, + docxPath, + docId, + relativePath, + }); + + importMs = rendered.timings.importMs; + editorInitMs = rendered.timings.editorInitMs; + layoutWaitMs = rendered.timings.layoutWaitMs; + layoutMs = rendered.timings.layoutMs; + + const serializeStartedAtMs = nowMs(); + const exportPayload = { + formatVersion: 1, + exportedAt: new Date().toISOString(), + source: { + docxAbsolutePath: docxPath, + docxRelativePath: relativePath, + inputRoot, + }, + runtime: { + nodeVersion: process.version, + isBun: typeof Bun !== 'undefined', + usingStubCanvas: envInfo.usingStubCanvas, + superEditorModule: args.module, + telemetryEnabled: args.telemetryEnabled, + pipeline: args.pipeline, + ...rendered.pipelineRuntime, + }, + layoutSnapshot: rendered.snapshot, + layoutOptions: rendered.layoutOptions, + metrics: rendered.metrics, + }; + + const jsonSafe = toJsonSafe(exportPayload); + const jsonOutput = JSON.stringify(jsonSafe, null, 2); + serializeMs = nowMs() - serializeStartedAtMs; + + const writeStartedAtMs = nowMs(); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, jsonOutput, 'utf8'); + writeMs = nowMs() - writeStartedAtMs; + + successCount += 1; + const docElapsedMs = nowMs() - docStartedAtMs; + + phaseTotals.importMs += importMs; + phaseTotals.editorInitMs += editorInitMs; + phaseTotals.layoutWaitMs += layoutWaitMs; + phaseTotals.layoutMs += layoutMs; + phaseTotals.serializeMs += serializeMs; + phaseTotals.writeMs += writeMs; + phaseTotals.totalMs += docElapsedMs; + + const pageCount = rendered.pageCount ?? rendered.snapshot?.layout?.pages?.length ?? 0; + + const phaseLabel = + args.pipeline === 'presentation' + ? `import ${formatDuration(importMs)}, editorInit ${formatDuration(editorInitMs)}, waitLayout ${formatDuration(layoutWaitMs)}, layoutTotal ${formatDuration(layoutMs)}, serialize ${formatDuration(serializeMs)}, write ${formatDuration(writeMs)}` + : `import ${formatDuration(importMs)}, editorInit ${formatDuration(editorInitMs)}, layoutTotal ${formatDuration(layoutMs)}, serialize ${formatDuration(serializeMs)}, write ${formatDuration(writeMs)}`; + + logDocProgress({ progress, relativePath, pageCount, docElapsedMs, phaseLabel }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const docElapsedMs = nowMs() - docStartedAtMs; + failures.push({ path: docxPath, message, elapsedMs: docElapsedMs }); + logDocFailure({ progress, relativePath, docElapsedMs, message }); + if (args.failFast) { + break; + } + } + } + + const elapsedMs = Date.now() - startedAt; + return { + elapsedMs, + successCount, + failures, + phaseTotals, + }; +} + +async function loadWorkerDocEntries(args) { + if (!args.workerManifestPath) { + throw new Error('Worker mode requires --worker-manifest .'); + } + const raw = await fs.readFile(args.workerManifestPath, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.docs)) { + throw new Error(`Invalid worker manifest format: ${args.workerManifestPath}`); + } + return parsed.docs; +} + +async function run() { + const args = parseArgs(process.argv.slice(2)); + const restoreConsoleFilter = installTelemetryConsoleFilter(args.telemetryEnabled); + const moduleUrl = await resolveModuleUrl(args.module); + const inputRoot = path.resolve(args.inputRoot); + const outputRoot = path.resolve(args.outputRoot); + + try { + const runtime = await loadRuntimeModule(moduleUrl, { + requireHeadlessPrimitives: args.pipeline === 'headless', + }); + const moduleExports = runtime.moduleExports; + const headlessPrimitives = runtime.headlessPrimitives; + + const envInfo = installDomEnvironment(); + if (envInfo.usingStubCanvas) { + console.warn( + '[layout-snapshots] Native canvas is unavailable in this runtime; using approximate text metrics (not pixel-perfect).', + ); + } + + let allDocxFiles = []; + let docEntries = []; + + if (args.isWorker) { + docEntries = await loadWorkerDocEntries(args); + allDocxFiles = docEntries.map((entry) => (typeof entry === 'string' ? entry : entry.path)); + } else { + allDocxFiles = await listDocxFiles(inputRoot); + const limited = args.limit ? allDocxFiles.slice(0, args.limit) : allDocxFiles; + docEntries = limited.map((docxPath, index) => ({ path: docxPath, index: index + 1 })); + } + + const totalDocs = args.totalDocs && Number.isFinite(args.totalDocs) && args.totalDocs > 0 ? args.totalDocs : docEntries.length; + + if (!args.isWorker) { + assertSafeOutputRoot(outputRoot); + if (args.cleanOutput) { + await fs.rm(outputRoot, { recursive: true, force: true }); + } + await fs.mkdir(outputRoot, { recursive: true }); + + logLine(`[layout-snapshots] Input root: ${inputRoot}`); + logLine(`[layout-snapshots] Output root: ${outputRoot}`); + logLine(`[layout-snapshots] Docs found: ${allDocxFiles.length}`); + logLine(`[layout-snapshots] Docs to run: ${docEntries.length}`); + logLine(`[layout-snapshots] Module: ${moduleUrl}`); + logLine(`[layout-snapshots] Pipeline: ${args.pipeline}`); + logLine(`[layout-snapshots] Jobs: ${args.jobs}`); + logLine(`[layout-snapshots] Telemetry: ${args.telemetryEnabled ? 'enabled' : 'disabled'}`); + } + + let summary; + const wallStart = Date.now(); + + if (!args.isWorker && args.jobs > 1) { + summary = await runWorkers({ + args, + moduleUrl, + docs: docEntries, + totalDocs, + inputRoot, + outputRoot, + }); + summary.elapsedMs = Date.now() - wallStart; + } else { + summary = await runDocBatch({ + args, + inputRoot, + outputRoot, + moduleUrl, + envInfo, + moduleExports, + headlessPrimitives, + docEntries, + totalDocs, + }); + } + + if (args.summaryFile) { + await fs.mkdir(path.dirname(path.resolve(args.summaryFile)), { recursive: true }); + await fs.writeFile(path.resolve(args.summaryFile), JSON.stringify(summary), 'utf8'); + } + + if (!args.suppressFinalSummary) { + printFinalSummary({ + ...summary, + outputRoot, + }); + } + + if (summary.failures?.length > 0) { + process.exitCode = 1; + } + } finally { + restoreConsoleFilter(); + } +} + +run().catch((error) => { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + errorLine(`[layout-snapshots] Fatal: ${message}`); + process.exit(1); +}); diff --git a/tests/layout-snapshots/shared.mjs b/tests/layout-snapshots/shared.mjs new file mode 100644 index 0000000000..e74bec7f1b --- /dev/null +++ b/tests/layout-snapshots/shared.mjs @@ -0,0 +1,9 @@ +/** + * Shared utilities for layout snapshot scripts. + */ + +export function normalizeVersionLabel(version) { + const trimmed = String(version ?? '').trim(); + if (!trimmed) return 'v.unknown'; + return trimmed.startsWith('v.') ? trimmed : `v.${trimmed}`; +} diff --git a/tests/visual/CLAUDE.md b/tests/visual/CLAUDE.md index 195ca1c73a..feb858e664 100644 --- a/tests/visual/CLAUDE.md +++ b/tests/visual/CLAUDE.md @@ -29,7 +29,7 @@ tests/ structured-content/ SDT lock modes rendering/ Auto-discovers all .docx in test-data/rendering/ fixtures/superdoc.ts Shared fixture with helpers -test-data/ Downloaded from R2 (gitignored), mirrors R2 documents/ prefix +test-data/ Symlink to shared repo corpus mirror (`/test-corpus`) scripts/ download-test-docs.ts Auto-discover and download all documents from R2 upload-test-doc.ts Upload rendering doc — prompts for issue ID and description @@ -39,17 +39,9 @@ scripts/ ## R2 Storage -Single bucket with two prefixes. Local `test-data/` mirrors the `documents/` prefix exactly: - -``` -superdoc-visual-testing/ - documents/ → downloads to test-data/ - behavior/ - comments-tcs/doc.docx → test-data/behavior/comments-tcs/doc.docx - formatting/doc.docx → test-data/behavior/formatting/doc.docx - rendering/doc.docx → test-data/rendering/doc.docx - baselines/ → downloads to tests/ (snapshot dirs) -``` +DOCX files are stored in a shared corpus bucket as plain relative keys plus `registry.json`. +`pnpm docs:download` syncs corpus files into `/test-corpus` and links `tests/visual/test-data` to that shared root. +Visual baseline images are stored separately under the `baselines/` prefix. ## Adding a Rendering Test @@ -58,7 +50,7 @@ Rendering tests are auto-discovered. Just upload a document: ```bash pnpm docs:upload ~/Downloads/my-file.docx # Prompts: Linear issue ID, short description -# → uploads to documents/rendering/sd-1679-anchor-table-overlap.docx +# → uploads to rendering/sd-1679-anchor-table-overlap.docx pnpm docs:download # pull the new file locally pnpm test # verify it loads and renders @@ -87,7 +79,7 @@ Place the file in the matching category folder. Use `@behavior` tag in the test ## Loading Test Documents -Test documents are stored in R2 (`documents/` prefix). Download with `pnpm docs:download`. Upload rendering docs with `pnpm docs:upload `. +Test documents are stored in the shared corpus bucket. Download with `pnpm docs:download`. Upload rendering docs with `pnpm docs:upload `. ```ts import fs from 'node:fs'; diff --git a/tests/visual/README.md b/tests/visual/README.md index 355e68436b..537331cb4e 100644 --- a/tests/visual/README.md +++ b/tests/visual/README.md @@ -1,6 +1,6 @@ # Visual Testing -Playwright-based visual regression tests for SuperDoc. Everything lives in a single R2 bucket (`superdoc-visual-testing`) with two prefixes: `documents/` for test files and `baselines/` for screenshots. +Playwright-based visual regression tests for SuperDoc. Test DOCX files are synced from the shared R2 corpus into the repo-level `test-corpus/` mirror (and linked into `tests/visual/test-data`). Baselines are stored in R2. ## Quick Start @@ -67,7 +67,7 @@ Rendering tests are auto-discovered from `test-data/rendering/`. Just upload a d ```bash pnpm docs:upload ~/Downloads/my-doc.docx # Prompts for: Linear issue ID, short description -# → uploads as documents/rendering/sd-1679-anchor-table-overlap.docx +# → uploads as rendering/sd-1679-anchor-table-overlap.docx in the shared corpus pnpm docs:download # pull the new file locally pnpm test # verify it loads and renders @@ -77,17 +77,22 @@ No spec file needed — `rendering.spec.ts` auto-discovers all `.docx` files. Ba ## R2 Storage -Everything lives in one bucket. The folder structure mirrors the test structure: +Corpus files are stored in a shared R2 bucket as plain relative keys (plus `registry.json`), for example: ``` -superdoc-visual-testing/ - documents/ Test .docx files - behavior/ - comments-tcs/ Documents for comments-tcs tests - formatting/ Documents for formatting tests - ... - rendering/ Documents for rendering tests - baselines/ Screenshot baselines (auto-generated) +registry.json +basic/advanced-tables.docx +comments-tcs/tracked-changes.docx +rendering/sd-1679-anchor-table-overlap.docx +... +``` + +`pnpm docs:download` syncs that corpus into repo-local `test-corpus/` and links `tests/visual/test-data` to it. + +Screenshot baselines remain in R2 and are auto-generated in CI: + +``` +baselines/ behavior/ basic-commands/ type-basic-text.spec.ts-snapshots/ @@ -101,8 +106,8 @@ superdoc-visual-testing/ | Command | What it does | |---------|-------------| -| `pnpm docs:download` | Download all documents from R2 → `test-data/` | -| `pnpm docs:upload ` | Upload a rendering test document to R2 (prompts for issue ID and description) | +| `pnpm docs:download` | Sync shared corpus from R2 → `test-corpus/` and link `test-data/` | +| `pnpm docs:upload ` | Upload a rendering test document to the shared corpus (prompts for issue ID and description) | ## Fixture Helpers diff --git a/tests/visual/scripts/download-test-docs.ts b/tests/visual/scripts/download-test-docs.ts index f2d724b285..685d4c50a5 100644 --- a/tests/visual/scripts/download-test-docs.ts +++ b/tests/visual/scripts/download-test-docs.ts @@ -1,73 +1,38 @@ /** - * Downloads all test documents from R2. - * Auto-discovers everything under the documents/ prefix — no hardcoded list. - * Downloads to test-data/ preserving the folder structure. + * Proxy to the repo-level corpus downloader. + * + * This keeps `pnpm docs:download` stable for tests/visual while using the + * shared corpus root consumed by layout snapshots. */ -import fs from 'node:fs'; import path from 'node:path'; -import { createR2Client, ensureR2Auth, DOCUMENTS_PREFIX } from './r2.js'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; -const TEST_DATA_DIR = path.resolve(import.meta.dirname, '../test-data'); +const REPO_ROOT = path.resolve(import.meta.dirname, '../../..'); async function main() { - ensureR2Auth(); - - const client = await createR2Client(); - - console.log('Listing documents in R2...'); - const keys = await client.listObjects(DOCUMENTS_PREFIX); - - if (keys.length === 0) { - console.log('No documents found in R2.'); - process.exit(0); - } - - const quiet = !!process.env.CI; - console.log(`Found ${keys.length} documents.`); - - const toDownload: { key: string; relative: string; dest: string }[] = []; - let skipped = 0; - - for (const key of keys) { - const relative = key.slice(`${DOCUMENTS_PREFIX}/`.length); - const dest = path.join(TEST_DATA_DIR, relative); - - if (fs.existsSync(dest)) { - skipped++; - } else { - toDownload.push({ key, relative, dest }); - } - } - - console.log(`Downloading ${toDownload.length} files (${skipped} cached)...`); - - const CONCURRENCY = 10; - let downloaded = 0; - let failed = 0; - - for (let i = 0; i < toDownload.length; i += CONCURRENCY) { - const batch = toDownload.slice(i, i + CONCURRENCY); - const results = await Promise.allSettled( - batch.map(async ({ key, relative, dest }) => { - await client.getObject(key, dest); - downloaded++; - if (!quiet) console.log(` ✓ ${relative}`); - }), - ); - - for (let j = 0; j < results.length; j++) { - if (results[j].status === 'rejected') { - failed++; - if (!quiet) console.error(` ✗ ${batch[j].relative}: ${(results[j] as PromiseRejectedResult).reason?.message}`); - } - } - } - - console.log(`\nDone. Downloaded: ${downloaded}, Cached: ${skipped}, Failed: ${failed}`); - client.destroy(); + const passthroughArgs = process.argv.slice(2).filter((arg) => arg !== '--'); + const commandArgs = ['run', 'corpus:pull', '--', '--link-visual', ...passthroughArgs]; + + const child = spawn('pnpm', commandArgs, { + cwd: REPO_ROOT, + env: process.env, + stdio: 'inherit', + }); + + const exitCode = await new Promise((resolve) => { + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', (err) => { + console.error(`Failed to spawn corpus:pull: ${err.message}`); + resolve(1); + }); + }); + + process.exit(Number(exitCode)); } -main().catch((err) => { - console.error(err); +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[docs:download] Fatal: ${message}`); process.exit(1); }); diff --git a/tests/visual/scripts/upload-test-doc.ts b/tests/visual/scripts/upload-test-doc.ts index ba00c5e055..a4fa391ed8 100644 --- a/tests/visual/scripts/upload-test-doc.ts +++ b/tests/visual/scripts/upload-test-doc.ts @@ -5,16 +5,18 @@ * pnpm docs:upload * * Prompts for an optional Linear issue ID and a short description, - * then uploads to documents/rendering/-.docx. + * then uploads to rendering/-.docx in the shared corpus. * * Examples: * pnpm docs:upload ~/Downloads/bug-repro.docx */ -import { execSync } from 'node:child_process'; +import { execSync, spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import process from 'node:process'; import { intro, outro, text, confirm, cancel, isCancel, log } from '@clack/prompts'; -import { createR2Client, ensureR2Auth, DOCUMENTS_PREFIX } from './r2.js'; + +const REPO_ROOT = path.resolve(import.meta.dirname, '../../..'); function toKebab(str: string): string { return str @@ -45,8 +47,6 @@ async function main() { process.exit(1); } - ensureR2Auth(); - intro(`Upload: ${path.basename(resolved)}`); const issueId = exitIfCancelled( @@ -73,18 +73,31 @@ async function main() { const parts = [issueId ? toKebab(issueId) : null, description].filter(Boolean); const fileName = `${parts.join('-')}.docx`; - const key = `${DOCUMENTS_PREFIX}/rendering/${fileName}`; + const targetRelativePath = `rendering/${fileName}`; - const confirmed = exitIfCancelled(await confirm({ message: `Upload as ${key}?` })); + const confirmed = exitIfCancelled(await confirm({ message: `Upload as ${targetRelativePath}?` })); if (!confirmed) { cancel('Upload cancelled.'); process.exit(0); } - const client = await createR2Client(); - await client.putObject(key, resolved, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); - client.destroy(); + const uploadArgs = ['run', 'corpus:push', '--', '--path', targetRelativePath, resolved]; + const uploadChild = spawn('pnpm', uploadArgs, { + cwd: REPO_ROOT, + env: process.env, + stdio: 'inherit', + }); + const uploadExitCode = await new Promise((resolve) => { + uploadChild.on('close', (code) => resolve(code ?? 1)); + uploadChild.on('error', (err) => { + console.error(`Failed to spawn corpus:push: ${err.message}`); + resolve(1); + }); + }); + if (uploadExitCode !== 0) { + throw new Error(`Corpus upload failed with exit code ${uploadExitCode}.`); + } // Trigger baseline generation if gh CLI is available let triggered = false;