mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
295 lines
12 KiB
TypeScript
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",
|
|
]),
|
|
);
|
|
});
|