Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions devtools/visual-testing/packages/test-helpers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
"./stability": "./src/stability.ts",
"./interactions": "./src/interactions.ts"
},
"dependencies": {
"@superdoc-testing/harness": "workspace:*"
},
"peerDependencies": {
"@playwright/test": ">=1.40.0"
},
Expand Down
92 changes: 89 additions & 3 deletions devtools/visual-testing/scripts/compare-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { generateResultsFolderName, getSuperdocVersion } from './generate-refs.js';
import { findPngFiles } from './compare.js';
import { findPngFiles, matchesFilterWithBrowserPrefix } from './compare.js';
import { colors } from './terminal.js';
import { resolveBrowserNames } from './browser-utils.js';
import { normalizePath } from './utils.js';
import { BROWSER_NAMES, resolveBaselineFolderForBrowser, resolveBrowserNames } from './browser-utils.js';
import {
isPathLikeVersion,
normalizeVersionLabel,
Expand All @@ -38,6 +39,35 @@ import { ensureLocalTarballInstalled } from './workspace-utils.js';

const BASELINES_DIR = 'baselines-interactions';

function listFilteredPngs(dir: string, filters: string[], matches: string[], excludes: string[]): string[] {
return findPngFiles(dir)
.map((relativePath) => normalizePath(relativePath))
.filter((relativePath) => matchesFilterWithBrowserPrefix(relativePath, undefined, filters, matches, excludes));
}

function findMissingBaselineDocFilters(options: {
baselineFolder: string;
resultsFolder: string;
filters: string[];
matches: string[];
excludes: string[];
}): string[] {
const baselineFiles = new Set(
listFilteredPngs(options.baselineFolder, options.filters, options.matches, options.excludes),
);
const resultFiles = listFilteredPngs(options.resultsFolder, options.filters, options.matches, options.excludes);
const missingDocs = new Set<string>();

for (const resultPath of resultFiles) {
if (baselineFiles.has(resultPath)) continue;
const docKey = path.posix.dirname(resultPath);
if (!docKey || docKey === '.') continue;
missingDocs.add(docKey);
}

return Array.from(missingDocs);
}

interface CompareInteractionArgs {
baselineVersion?: string;
targetVersion?: string;
Expand Down Expand Up @@ -451,8 +481,9 @@ async function main(): Promise<void> {
await runGenerate(resultsFolderName, filters, matches, excludes, browserArg, scaleFactor, storageArgs);
}

let resultsRoot: string | undefined;
if (resultsFolderName) {
const resultsRoot = path.isAbsolute(resultsFolderName)
resultsRoot = path.isAbsolute(resultsFolderName)
? path.join(resultsFolderName, 'interactions')
: path.join('screenshots', resultsFolderName, 'interactions');
const hasBrowserResults = browsers.some((browser) => fs.existsSync(path.join(resultsRoot, browser)));
Expand All @@ -466,6 +497,61 @@ async function main(): Promise<void> {
}
}

if (mode === 'cloud' && !refreshBaselines && resultsFolderName && resultsRoot) {
const baselineVersionDir = path.join(baselineDir, baselineToUse);
const resultsHasBrowserDirs = BROWSER_NAMES.some((browser) => fs.existsSync(path.join(resultsRoot, browser)));

for (const browser of browsers) {
if (!resultsHasBrowserDirs && browser !== 'chromium') {
continue;
}
const resultsFolder = resultsHasBrowserDirs ? path.join(resultsRoot, browser) : resultsRoot;
if (!fs.existsSync(resultsFolder)) {
continue;
}
const baselineFolder = resolveBaselineFolderForBrowser(baselineVersionDir, browser);
if (!fs.existsSync(baselineFolder)) {
continue;
}

const missingFilters = findMissingBaselineDocFilters({
baselineFolder,
resultsFolder,
filters,
matches,
excludes,
});
if (missingFilters.length === 0) {
continue;
}

console.log(
colors.muted(
`↻ Missing interaction baselines detected in cache. Refreshing ${missingFilters.length} story(s) for ${browser}...`,
),
);
const refreshed = await refreshBaselineSubset({
prefix: BASELINES_DIR,
version: baselineToUse,
localRoot: baselineDir,
filters: missingFilters,
excludes,
browsers: [browser],
});
if (refreshed.matched === 0) {
console.warn(
colors.warning(`No interaction baseline files matched for refresh (${browser}). Keeping current cache.`),
);
} else {
console.log(
colors.success(
`↻ Refreshed ${refreshed.downloaded} interaction baseline file(s) for ${baselineToUse} (${browser}).`,
),
);
}
}
}

await runCompare(
resultsFolderName,
baselineToUse,
Expand Down
85 changes: 73 additions & 12 deletions devtools/visual-testing/scripts/compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import { generateResultsFolderName, getSuperdocVersion, sanitizeFilename } from './generate-refs.js';
Expand All @@ -45,7 +45,6 @@ import {
resolveBaselineFolderForBrowser,
type BrowserName,
} from './browser-utils.js';
import { sleep, createLogBuffer } from './utils.js';
import { ensureBaselineDownloaded, getLatestBaselineVersion, refreshBaselineSubset } from './r2-baselines.js';
import {
buildStorageArgs,
Expand All @@ -55,16 +54,7 @@ import {
resolveDocsDir,
type StorageMode,
} from './storage-flags.js';
import {
HARNESS_PORT,
HARNESS_URL,
HARNESS_START_TIMEOUT_MS,
HARNESS_LOG_BUFFER_LIMIT,
isPortOpen,
waitForPort,
ensureHarnessRunning,
stopHarness,
} from './harness-utils.js';
import { HARNESS_PORT, HARNESS_URL, isPortOpen, ensureHarnessRunning, stopHarness } from './harness-utils.js';
import { ensureLocalTarballInstalled } from './workspace-utils.js';

// Configuration
Expand Down Expand Up @@ -702,6 +692,31 @@ function extractAssetPath(relativePath: string, resultsFolderName: string, resul
return assetPath;
}

function deriveMissingBaselineDocFilters(
report: ComparisonReport,
resultsFolderName: string,
resultsPrefix: string | undefined,
browser?: BrowserName,
): string[] {
const filters = new Set<string>();

for (const result of report.results) {
if (result.reason !== 'missing_in_baseline') continue;
const assetPath = extractAssetPath(result.relativePath, resultsFolderName, resultsPrefix);
const normalized = normalizePath(assetPath);
let docKey = path.posix.dirname(normalized);
if (!docKey || docKey === '.') continue;
if (browser && docKey.startsWith(`${browser}/`)) {
docKey = docKey.slice(browser.length + 1);
}
if (docKey && docKey !== '.') {
filters.add(docKey);
}
}

return Array.from(filters);
}

function readStoryMetadata(filePath: string): StoryMetadataFile | null {
if (!fs.existsSync(filePath)) return null;
try {
Expand Down Expand Up @@ -2148,6 +2163,52 @@ async function main(): Promise<void> {
},
});

if (mode === 'cloud' && !refreshBaselines && report.summary.missingInBaseline > 0) {
const refreshFilters = deriveMissingBaselineDocFilters(report, resultsFolderName!, resultsPrefix, browser);
if (refreshFilters.length > 0) {
console.log(
colors.muted(
`↻ Missing baseline files detected in cache. Refreshing ${refreshFilters.length} doc(s) from R2...`,
),
);
const refreshed = await refreshBaselineSubset({
prefix: baselinePrefix,
version: baselineToUse,
localRoot: baselineDir,
filters: refreshFilters,
excludes,
browsers: [browser],
});
if (refreshed.matched > 0) {
report = await runComparison(resultsFolderName!, {
threshold,
baselineVersion: baselineToUse,
baselineRoot: baselineDir,
resultsRoot: resolvedResultsRoot,
resultsPrefix,
browser,
outputFolderName,
filters,
matches,
excludes,
ignorePrefixes,
reportOptions: {
showAll: reportAll,
reportFileName,
mode: resolvedMode,
trimPrefix: resolvedTrim,
},
});
} else {
console.warn(colors.warning('No baseline files matched for refresh; keeping current comparison results.'));
}
} else {
console.warn(
colors.warning('Missing baseline files detected but no doc filters could be derived for refresh.'),
);
}
}

if (resolvedMode === 'visual' && includeWord) {
const wordResultsPrefix = browser ? `${normalizePrefix(resultsPrefix) ?? ''}${browser}/` : resultsPrefix;
report = await augmentReportWithWord(report, {
Expand Down
4 changes: 4 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ export type ImageBlock = {
margin?: BoxSpacing;
anchor?: ImageAnchor;
wrap?: ImageWrap;
/** Stacking order from OOXML relativeHeight (same formula as editor: Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE)) */
zIndex?: number;
attrs?: ImageBlockAttrs;
// VML image adjustments for watermark effects
gain?: string | number; // Brightness/washout (VML hex string or number)
Expand Down Expand Up @@ -1587,6 +1589,7 @@ export type ImageFragment = {
width: number;
height: number;
isAnchored?: boolean;
behindDoc?: boolean;
zIndex?: number;
pmStart?: number;
pmEnd?: number;
Expand All @@ -1602,6 +1605,7 @@ export type DrawingFragment = {
width: number;
height: number;
isAnchored?: boolean;
behindDoc?: boolean;
zIndex?: number;
geometry: DrawingGeometry;
scale: number;
Expand Down
3 changes: 2 additions & 1 deletion packages/layout-engine/layout-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"test": "vitest run"
},
"dependencies": {
"@superdoc/common": "workspace:*",
"@superdoc/contracts": "workspace:*",
"@superdoc/common": "workspace:*"
"@superdoc/pm-adapter": "workspace:*"
}
}
5 changes: 3 additions & 2 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { createPaginator, type PageState, type ConstraintBoundary } from './pagi
import { formatPageNumber } from './pageNumbering.js';
import { shouldSuppressSpacingForEmpty } from './layout-utils.js';
import { balancePageColumns } from './column-balancing.js';
import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js';

type PageSize = { w: number; h: number };
type Margins = {
Expand Down Expand Up @@ -1062,7 +1063,6 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
if (!state) {
// Track if we're entering a new section (pendingSectionIndex was just set)
const isEnteringNewSection = pendingSectionIndex !== null;
const newSectionIndex = isEnteringNewSection ? pendingSectionIndex : activeSectionIndex;

const applied = applyPendingToActive({
activeTopMargin,
Expand Down Expand Up @@ -1956,7 +1956,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
width: imgMeasure.width,
height: imgMeasure.height,
isAnchored: true,
zIndex: imgBlock.anchor?.behindDoc ? 0 : 1,
behindDoc: imgBlock.anchor?.behindDoc === true,
zIndex: getFragmentZIndex(imgBlock),
metadata,
};

Expand Down
3 changes: 2 additions & 1 deletion packages/layout-engine/layout-engine/src/layout-drawing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DrawingBlock, DrawingMeasure, DrawingFragment } from '@superdoc/co
import type { NormalizedColumns } from './layout-image.js';
import type { PageState } from './paginator.js';
import { extractBlockPmRange } from './layout-utils.js';
import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js';

/**
* Context for laying out a drawing block (vector shape) within the page layout.
Expand Down Expand Up @@ -118,7 +119,7 @@ export function layoutDrawingBlock({
geometry: measure.geometry,
scale: measure.scale,
drawingContentId: block.drawingContentId,
zIndex: block.zIndex,
zIndex: getFragmentZIndex(block),
pmStart: pmRange.pmStart,
pmEnd: pmRange.pmEnd,
};
Expand Down
8 changes: 5 additions & 3 deletions packages/layout-engine/layout-engine/src/layout-paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isEmptyTextParagraph,
} from './layout-utils.js';
import { computeAnchorX } from './floating-objects.js';
import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js';

const spacingDebugEnabled = false;
/**
Expand Down Expand Up @@ -388,7 +389,8 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
width: entry.measure.width,
height: entry.measure.height,
isAnchored: true,
zIndex: entry.block.anchor?.behindDoc ? 0 : 1,
behindDoc: entry.block.anchor?.behindDoc === true,
zIndex: getFragmentZIndex(entry.block),
metadata,
};
if (pmRange.pmStart != null) fragment.pmStart = pmRange.pmStart;
Expand All @@ -406,7 +408,8 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
geometry: entry.measure.geometry,
scale: entry.measure.scale,
isAnchored: true,
zIndex: entry.block.anchor?.behindDoc ? 0 : 1,
behindDoc: entry.block.anchor?.behindDoc === true,
zIndex: getFragmentZIndex(entry.block),
drawingContentId: entry.block.drawingContentId,
};
if (pmRange.pmStart != null) fragment.pmStart = pmRange.pmStart;
Expand All @@ -432,7 +435,6 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
const negativeRightIndent = indentRight < 0 ? indentRight : 0;
// Paragraph content width should honor paragraph indents (including negative values).
const remeasureWidth = Math.max(1, columnWidth - indentLeft - indentRight);
const hasNegativeIndent = indentLeft < 0 || indentRight < 0;
let didRemeasureForColumnWidth = false;
// Track remeasured marker info to ensure fragment gets accurate marker text width
let remeasuredMarkerInfo: ParagraphMeasure['marker'] | undefined;
Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/painters/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@superdoc/contracts": "workspace:*",
"@superdoc/font-utils": "workspace:*",
"@superdoc/pm-adapter": "workspace:*",
"@superdoc/preset-geometry": "workspace:*",
"@superdoc/url-validation": "workspace:*"
},
Expand Down
Loading