Files
SnapOtter/tests/unit/surface-playwright-harness.test.ts
T
SnapOtterandGitHub 42e1dc9799 fix(docs): keep the translated locale trees out of the search index (#662)
Search Console flagged four reasons on 2026-07-28: soft 404, both
duplicate-canonical variants, and noindex. All four sat on
docs.snapotter.com; the landing site was clean.

The cause was boilerplate dominance rather than a broken tag.
/tools/video/crop-video carries 1.3 KB of unique body against 3.5 KB of
identical chrome (nav, sidebar, 21-language switcher), so unrelated tool
docs measured 52-60% full-page similarity. Across 20 locales that was
3,640 of 3,822 submitted URLs. Google read the lot as one duplicate
cluster and began electing arbitrary representatives: /changelog became
the canonical for /tools/image/favicon, and /uk/guide/getting-started for
/nl/tools/image/resize. English tool docs indexed 2 of 10 and localized 4
of 10, while the landing page for those same five tools indexed 5 of 5.

Translated pages now emit `noindex, follow` with a self-canonical, and
sitemap.transformItems drops them from the sitemap. Self-canonical rather
than pointing at English, since noindex paired with a cross-canonical
sends two conflicting instructions. hreflang is removed outright: the
annotation only means something between pages that can all be indexed.

Readers see no change. The language switcher and every in-page link
behave exactly as before.

Verified against a real build: sitemap 3,822 to 182 URLs with zero
translated entries, all 3,640 translated files carrying the noindex and
no English file doing so, docs e2e 100 passed.
2026-07-28 17:08:32 +08:00

295 lines
12 KiB
TypeScript

import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
const root = process.cwd();
const envKeys = [
"EDITOR_TEST_PORT",
"PLAYWRIGHT_DEMO_PORT",
"PLAYWRIGHT_DEMO_URL",
"PLAYWRIGHT_DOCS_PORT",
"PLAYWRIGHT_DOCS_URL",
"PLAYWRIGHT_EDITOR_API_PORT",
"PLAYWRIGHT_EDITOR_API_URL",
"PLAYWRIGHT_EDITOR_WEB_PORT",
"PLAYWRIGHT_EDITOR_WEB_URL",
"PLAYWRIGHT_LANDING_PORT",
"PLAYWRIGHT_LANDING_URL",
"PLAYWRIGHT_RUN_ID",
"PLAYWRIGHT_RUN_ROOT",
] as const;
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
afterEach(() => {
vi.resetModules();
for (const key of envKeys) {
const value = originalEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
async function loadConfig(modulePath: string, overrides: Record<string, string>) {
for (const key of envKeys) delete process.env[key];
for (const [key, value] of Object.entries(overrides)) process.env[key] = value;
vi.resetModules();
return (await import(modulePath)).default;
}
function expectScopedArtifacts(
config: { outputDir?: string; reporter?: unknown },
runId: string,
scope: string,
) {
const reporters = config.reporter as Array<[string, Record<string, string>]>;
const serialized = JSON.stringify(reporters);
expect(config.outputDir).toContain(path.join("test-results", "e2e-runs", runId, scope));
expect(serialized).toContain(path.join("test-results", "e2e-runs", runId, scope));
expect(serialized).not.toContain(path.join("test-results", "qa-report"));
expect(serialized).not.toContain(path.join("docs", "qa"));
}
/**
* The release contract is that a public-surface suite tests the artifact we ship,
* which is the emitted static output. A dev server applies its own transforms and
* serves routes the build never writes, so a suite pointed at one can pass over a
* broken build. The landing harness ran `astro dev` until a QA sweep caught it.
*/
describe("surface harnesses serve built output, never a dev server", () => {
const DEV_SERVERS = ["astro dev", "vitepress dev", "vite dev", "vite serve", "pnpm dev"];
test.each([
["landing", "../../playwright.landing.config.js", "PLAYWRIGHT_LANDING_PORT", "44361", "build"],
["docs", "../../playwright.docs.config.js", "PLAYWRIGHT_DOCS_PORT", "44362", "docs:build"],
["demo", "../../playwright.demo.config.js", "PLAYWRIGHT_DEMO_PORT", "44363", "build"],
])("%s builds before it serves", async (scope, modulePath, portKey, port, buildScript) => {
const config = await loadConfig(modulePath, {
[portKey]: port,
PLAYWRIGHT_RUN_ID: `${scope}_built_output`,
});
const command = (config.webServer as { command: string }).command;
for (const dev of DEV_SERVERS) {
expect(command, `${scope} harness must not run "${dev}"`).not.toContain(dev);
}
// Order matters, not just presence: serving has to come after building.
const buildAt = command.indexOf(buildScript);
const serveAt = command.indexOf("preview");
expect(buildAt, `${scope} harness never builds`).toBeGreaterThanOrEqual(0);
expect(serveAt, `${scope} harness never serves a preview`).toBeGreaterThan(buildAt);
expect(command).toContain("&&");
});
/**
* Building before serving only helps if the harness waits long enough for the
* build to finish. `vitepress build` over ~3,800 pages plus Pagefind indexing
* runs about two minutes on a warm machine, so a 120s startup budget failed the
* whole docs suite with "Timed out waiting 120000ms from config.webServer"
* before a single test ran. This is a startup ceiling, not a per-test timeout,
* so a generous value costs nothing when the server comes up quickly.
*/
test("each harness allows enough startup time to finish its build", async () => {
const budgets: Array<[string, string, string, string, number]> = [
[
"landing",
"../../playwright.landing.config.js",
"PLAYWRIGHT_LANDING_PORT",
"44371",
120_000,
],
["docs", "../../playwright.docs.config.js", "PLAYWRIGHT_DOCS_PORT", "44372", 300_000],
["demo", "../../playwright.demo.config.js", "PLAYWRIGHT_DEMO_PORT", "44373", 60_000],
];
for (const [scope, modulePath, portKey, port, minimum] of budgets) {
const config = await loadConfig(modulePath, {
[portKey]: port,
PLAYWRIGHT_RUN_ID: `${scope}_startup_budget`,
});
const { timeout } = config.webServer as { timeout: number };
expect(timeout, `${scope} harness startup budget`).toBeGreaterThanOrEqual(minimum);
}
});
/**
* A docs fixture once did `rm -rf` on the German locale and regenerated it, so
* running the suite rewrote 182 tracked translation files. A harness that edits
* tracked content cannot be trusted to report on it.
*/
test("the docs harness runs no fixture script that could rewrite tracked content", async () => {
const config = await loadConfig("../../playwright.docs.config.js", {
PLAYWRIGHT_DOCS_PORT: "44364",
PLAYWRIGHT_RUN_ID: "docs_no_seed",
});
const command = (config.webServer as { command: string }).command;
expect(command).not.toMatch(/seed-|fixtures\//);
expect(command).not.toMatch(/\brm\b/);
expect(fs.existsSync(path.join(root, "tests/e2e-docs/fixtures"))).toBe(false);
});
});
describe("surface Playwright harness isolation", () => {
test("landing and docs use owned IPv4 endpoints and run-scoped artifacts", async () => {
const landing = await loadConfig("../../playwright.landing.config.js", {
PLAYWRIGHT_LANDING_PORT: "44351",
PLAYWRIGHT_RUN_ID: "landing_contract",
});
expectScopedArtifacts(landing, "landing_contract", "landing");
expect(landing.use?.baseURL).toBe("http://127.0.0.1:44351");
expect(landing.webServer).toMatchObject({
reuseExistingServer: false,
url: "http://127.0.0.1:44351",
});
expect(landing.webServer).not.toHaveProperty("port");
expect(landing.webServer.command).toContain("&& exec pnpm exec astro preview");
const docs = await loadConfig("../../playwright.docs.config.js", {
PLAYWRIGHT_DOCS_PORT: "44173",
PLAYWRIGHT_RUN_ID: "docs_contract",
});
expectScopedArtifacts(docs, "docs_contract", "docs");
expect(docs.use?.baseURL).toBe("http://127.0.0.1:44173");
expect(docs.webServer).toMatchObject({
reuseExistingServer: false,
url: "http://127.0.0.1:44173",
});
expect(docs.webServer).not.toHaveProperty("port");
expect(docs.webServer.command).toContain("&& exec pnpm exec vitepress preview");
});
test("demo builds and previews on an owned dynamic endpoint", async () => {
const config = await loadConfig("../../playwright.demo.config.js", {
PLAYWRIGHT_DEMO_PORT: "44174",
PLAYWRIGHT_RUN_ID: "demo_contract",
});
expectScopedArtifacts(config, "demo_contract", "demo");
expect(config.use?.baseURL).toBe("http://127.0.0.1:44174");
expect(config.webServer).toMatchObject({
reuseExistingServer: false,
url: "http://127.0.0.1:44174",
});
expect(config.webServer.command).toContain("@snapotter/demo build");
expect(config.webServer.command).toContain("&& exec pnpm");
expect(config.webServer.command).toContain("--strictPort");
});
test("editor owns fresh API, database, storage, and built web resources", async () => {
const config = await loadConfig("../../playwright.editor.config.js", {
PLAYWRIGHT_EDITOR_API_PORT: "44191",
PLAYWRIGHT_EDITOR_WEB_PORT: "45149",
PLAYWRIGHT_RUN_ID: "editor_contract",
});
expectScopedArtifacts(config, "editor_contract", "editor");
expect(config.use?.baseURL).toBe("http://127.0.0.1:45149");
const [api, web] = config.webServer as Array<{
command: string;
env: Record<string, string>;
gracefulShutdown?: { signal: string; timeout: number };
reuseExistingServer: boolean;
url: string;
}>;
expect(api.url).toBe("http://127.0.0.1:44191/api/v1/health");
expect(api.reuseExistingServer).toBe(false);
const databaseName = new URL(api.env.DATABASE_URL).pathname.slice(1);
expect(databaseName).toMatch(/^snapotter_e2e_editor_[a-f0-9]{24}$/);
expect(api.env.BULLMQ_PREFIX).toBe(databaseName);
expect(api.command).toContain(
"node tests/playwright-api-lifecycle.mjs editor_contract editor -- pnpm",
);
expect(api.command).not.toContain("e2e-pg-create-db.cjs");
expect(api.gracefulShutdown).toEqual({ signal: "SIGTERM", timeout: 30_000 });
for (const key of ["DATA_DIR", "FILES_STORAGE_PATH", "WORKSPACE_PATH"]) {
expect(api.env[key]).toContain(path.join("editor_contract", "editor"));
}
expect(web.url).toBe("http://127.0.0.1:45149");
expect(web.reuseExistingServer).toBe(false);
// Intent, not the script name: the editor harness has to build the web app and
// then preview what it built. Pinning the literal "@snapotter/web build" broke
// the moment the command moved to an explicit `vite build --outDir`, which was
// an isolation improvement rather than a regression.
const webBuildAt = web.command.indexOf("vite build");
const webServeAt = web.command.indexOf("vite preview");
expect(webBuildAt, "editor harness never builds the web app").toBeGreaterThanOrEqual(0);
expect(webServeAt, "editor harness never previews its build").toBeGreaterThan(webBuildAt);
expect(web.command).not.toContain("vite dev");
expect(web.command).toContain("&& exec pnpm");
expect(web.command).toContain("--strictPort");
expect(web.env.VITE_API_URL).toBe("http://127.0.0.1:44191");
});
test("rejects path-traversing run identities", async () => {
await expect(
loadConfig("../../playwright.landing.config.js", {
PLAYWRIGHT_LANDING_PORT: "44352",
PLAYWRIGHT_RUN_ID: "../shared",
}),
).rejects.toThrow(/PLAYWRIGHT_RUN_ID/);
});
});
test("production QA configs keep all reports and output inside their run scope", async () => {
const qa = await loadConfig("../qa/playwright.qa.config.js", {
PLAYWRIGHT_RUN_ID: "production_qa_contract",
});
expectScopedArtifacts(qa, "production_qa_contract", "production-qa");
const editorQa = await loadConfig("../qa/playwright.editor-qa.config.js", {
PLAYWRIGHT_RUN_ID: "production_editor_contract",
});
expectScopedArtifacts(editorQa, "production_editor_contract", "production-editor-qa");
});
test("editor diagnostic screenshots use Playwright-owned test output", () => {
const source = fs.readFileSync(
path.join(root, "tests/e2e-editor/editor-full-gui-test.spec.ts"),
"utf8",
);
expect(source).toContain("testInfo.outputPath");
expect(source).not.toContain('path.join(__dirname, "screenshots")');
});
test("docs Playwright collection includes explicit Axe coverage", () => {
const output = execFileSync(
"pnpm",
["exec", "playwright", "test", "--config", "playwright.docs.config.ts", "--list"],
{
cwd: root,
encoding: "utf8",
env: { ...process.env, PLAYWRIGHT_RUN_ID: "docs_a11y_contract" },
maxBuffer: 10 * 1024 * 1024,
stdio: "pipe",
},
);
expect(output).toContain("a11y.spec.ts");
for (const pageName of ["homepage", "getting started", "configuration", "REST API"]) {
expect(output).toContain(`${pageName} has no Axe violations`);
}
expect(output).toContain("Total: 100 tests in 7 files");
});
test("Turbo passes surface harness overrides through task boundaries", () => {
const turbo = JSON.parse(fs.readFileSync(path.join(root, "turbo.json"), "utf8")) as {
globalPassThroughEnv: string[];
};
expect(turbo.globalPassThroughEnv).toEqual(
expect.arrayContaining([
"EDITOR_TEST_PORT",
"PLAYWRIGHT_DEMO_PORT",
"PLAYWRIGHT_DEMO_URL",
"PLAYWRIGHT_DOCS_PORT",
"PLAYWRIGHT_DOCS_URL",
"PLAYWRIGHT_EDITOR_API_PORT",
"PLAYWRIGHT_EDITOR_API_URL",
"PLAYWRIGHT_EDITOR_WEB_PORT",
"PLAYWRIGHT_EDITOR_WEB_URL",
"PLAYWRIGHT_LANDING_PORT",
"PLAYWRIGHT_LANDING_URL",
"QA_RETRIES",
"QA_WORKERS",
]),
);
});