diff --git a/scripts/validate-selfhost-sourcemap.d.mts b/scripts/validate-selfhost-sourcemap.d.mts new file mode 100644 index 0000000000..dd14c74b08 --- /dev/null +++ b/scripts/validate-selfhost-sourcemap.d.mts @@ -0,0 +1,8 @@ +export type ValidateSourcemapOptions = { + bundlePath?: string; + mapPath?: string; + exists?: (path: string) => boolean; + readFile?: (path: string) => string; +}; + +export declare function validateSourcemap(options?: ValidateSourcemapOptions): { sourceCount: number }; diff --git a/scripts/validate-selfhost-sourcemap.mjs b/scripts/validate-selfhost-sourcemap.mjs index 2f6229d606..4e2266dccc 100644 --- a/scripts/validate-selfhost-sourcemap.mjs +++ b/scripts/validate-selfhost-sourcemap.mjs @@ -1,63 +1,93 @@ +#!/usr/bin/env node +// Validates that the self-host Docker build's dist/server.mjs + dist/server.mjs.map are structurally +// sound and resolve back to real repository source (not an empty/broken map). Gates error.stack +// symbolication for every self-hosted deployment. Invoked from release-selfhost.yml and selfhost.yml. +// +// #7458: validation is an injectable named export so unit tests can cover every failure branch without +// a real dist/ build. The CLI entrypoint below preserves the previous cwd-relative + exit-1 behavior. + import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; -const root = process.cwd(); -const bundlePath = resolve(root, "dist/server.mjs"); -const mapPath = resolve(root, "dist/server.mjs.map"); +/** + * @param {{ + * bundlePath?: string, + * mapPath?: string, + * exists?: (path: string) => boolean, + * readFile?: (path: string) => string, + * }} [options] + * @returns {{ sourceCount: number }} + */ +export function validateSourcemap(options = {}) { + const bundlePath = options.bundlePath ?? resolve(process.cwd(), "dist/server.mjs"); + const mapPath = options.mapPath ?? resolve(process.cwd(), "dist/server.mjs.map"); + const exists = options.exists ?? existsSync; + const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8")); -function fail(message) { - console.error(`self-host sourcemap validation failed: ${message}`); - process.exit(1); -} + if (!exists(bundlePath)) throw new Error("dist/server.mjs is missing"); + if (!exists(mapPath)) throw new Error("dist/server.mjs.map is missing"); -if (!existsSync(bundlePath)) fail("dist/server.mjs is missing"); -if (!existsSync(mapPath)) fail("dist/server.mjs.map is missing"); + const bundle = readFile(bundlePath); + if (!bundle.includes("//# sourceMappingURL=server.mjs.map")) { + throw new Error("dist/server.mjs is missing the server.mjs.map sourceMappingURL"); + } -const bundle = readFileSync(bundlePath, "utf8"); -if (!bundle.includes("//# sourceMappingURL=server.mjs.map")) { - fail("dist/server.mjs is missing the server.mjs.map sourceMappingURL"); -} + let map; + try { + map = JSON.parse(readFile(mapPath)); + } catch (error) { + throw new Error( + `dist/server.mjs.map is not valid JSON (${error instanceof Error ? error.message : String(error)})`, + ); + } -let map; -try { - map = JSON.parse(readFileSync(mapPath, "utf8")); -} catch (error) { - fail(`dist/server.mjs.map is not valid JSON (${error instanceof Error ? error.message : String(error)})`); -} + if (map.version !== 3) throw new Error("dist/server.mjs.map is not a version 3 source map"); + if (!Array.isArray(map.sources) || map.sources.length === 0) { + throw new Error("dist/server.mjs.map has no original sources"); + } + if (!Array.isArray(map.sourcesContent) || map.sourcesContent.length !== map.sources.length) { + throw new Error("dist/server.mjs.map must include sourcesContent for every original source"); + } + const serverSourceIndex = map.sources.findIndex((source) => String(source).endsWith("src/server.ts")); + if (serverSourceIndex === -1) { + throw new Error("dist/server.mjs.map does not include src/server.ts"); + } + if (map.sourcesContent[serverSourceIndex]?.trim() === "") { + throw new Error("dist/server.mjs.map has empty source content for src/server.ts"); + } + const repoSourceIndexes = map.sources + .map((source, index) => [String(source), index]) + .filter(([source]) => source.startsWith("../src/")) + .map(([, index]) => index); + if (repoSourceIndexes.length === 0) { + throw new Error("dist/server.mjs.map does not include repository sources"); + } + if ( + repoSourceIndexes.some( + (index) => typeof map.sourcesContent[index] !== "string" || map.sourcesContent[index].trim() === "", + ) + ) { + throw new Error("dist/server.mjs.map is missing source content for a repository source"); + } -if (map.version !== 3) fail("dist/server.mjs.map is not a version 3 source map"); -if (!Array.isArray(map.sources) || map.sources.length === 0) { - fail("dist/server.mjs.map has no original sources"); -} -if (!Array.isArray(map.sourcesContent) || map.sourcesContent.length !== map.sources.length) { - fail("dist/server.mjs.map must include sourcesContent for every original source"); + return { sourceCount: map.sources.length }; } -const serverSourceIndex = map.sources.findIndex((source) => - String(source).endsWith("src/server.ts"), -); -if (serverSourceIndex === -1) { - fail("dist/server.mjs.map does not include src/server.ts"); -} -if (map.sourcesContent[serverSourceIndex]?.trim() === "") { - fail("dist/server.mjs.map has empty source content for src/server.ts"); -} -const repoSourceIndexes = map.sources - .map((source, index) => [String(source), index]) - .filter(([source]) => source.startsWith("../src/")) - .map(([, index]) => index); -if (repoSourceIndexes.length === 0) { - fail("dist/server.mjs.map does not include repository sources"); + +function fail(message) { + console.error(`self-host sourcemap validation failed: ${message}`); + process.exit(1); } -if ( - repoSourceIndexes.some( - (index) => - typeof map.sourcesContent[index] !== "string" || - map.sourcesContent[index].trim() === "", - ) -) { - fail("dist/server.mjs.map is missing source content for a repository source"); + +function main() { + try { + const { sourceCount } = validateSourcemap(); + console.log(`self-host sourcemap validation passed (${sourceCount} original sources)`); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } } -console.log( - `self-host sourcemap validation passed (${map.sources.length} original sources)`, -); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/test/unit/validate-selfhost-sourcemap-script.test.ts b/test/unit/validate-selfhost-sourcemap-script.test.ts new file mode 100644 index 0000000000..bad6d37eae --- /dev/null +++ b/test/unit/validate-selfhost-sourcemap-script.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { validateSourcemap } from "../../scripts/validate-selfhost-sourcemap.mjs"; + +const BUNDLE = "/tmp/dist/server.mjs"; +const MAP = "/tmp/dist/server.mjs.map"; + +const VALID_BUNDLE = "export {};\n//# sourceMappingURL=server.mjs.map\n"; + +function validMap(overrides: Record = {}): string { + return JSON.stringify({ + version: 3, + sources: ["../src/server.ts"], + sourcesContent: ["export function start() {}\n"], + mappings: "AAAA", + ...overrides, + }); +} + +function harness(files: Record) { + return { + bundlePath: BUNDLE, + mapPath: MAP, + exists: (path: string) => Object.prototype.hasOwnProperty.call(files, path) && files[path] !== undefined, + readFile: (path: string) => { + const content = files[path]; + if (content === undefined) throw new Error(`ENOENT: ${path}`); + return content; + }, + }; +} + +describe("validate-selfhost-sourcemap.mjs (#7458)", () => { + it("passes a well-formed minimal source map", () => { + expect( + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: validMap(), + }), + ), + ).toEqual({ sourceCount: 1 }); + }); + + it("fails when the bundle is missing", () => { + expect(() => validateSourcemap(harness({ [MAP]: validMap() }))).toThrow("dist/server.mjs is missing"); + }); + + it("fails when the map is missing", () => { + expect(() => validateSourcemap(harness({ [BUNDLE]: VALID_BUNDLE }))).toThrow("dist/server.mjs.map is missing"); + }); + + it("fails when the bundle is missing the sourceMappingURL comment", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: "export {};\n", + [MAP]: validMap(), + }), + ), + ).toThrow("dist/server.mjs is missing the server.mjs.map sourceMappingURL"); + }); + + it("fails when the map is not valid JSON", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: "{ not json", + }), + ), + ).toThrow(/dist\/server\.mjs\.map is not valid JSON/); + }); + + it("fails when the map is not version 3", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: validMap({ version: 2 }), + }), + ), + ).toThrow("dist/server.mjs.map is not a version 3 source map"); + }); + + it("fails when sources is empty", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: validMap({ sources: [], sourcesContent: [] }), + }), + ), + ).toThrow("dist/server.mjs.map has no original sources"); + }); + + it("fails when sourcesContent length does not match sources", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: validMap({ sourcesContent: [] }), + }), + ), + ).toThrow("dist/server.mjs.map must include sourcesContent for every original source"); + }); + + it("fails when src/server.ts is missing from sources", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: validMap({ + sources: ["../src/other.ts"], + sourcesContent: ["export {}\n"], + }), + }), + ), + ).toThrow("dist/server.mjs.map does not include src/server.ts"); + }); + + it("fails when src/server.ts source content is empty", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: validMap({ + sources: ["../src/server.ts"], + sourcesContent: [" "], + }), + }), + ), + ).toThrow("dist/server.mjs.map has empty source content for src/server.ts"); + }); + + it("fails when no repository-relative ../src/ sources are present", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: validMap({ + sources: ["src/server.ts"], + sourcesContent: ["export function start() {}\n"], + }), + }), + ), + ).toThrow("dist/server.mjs.map does not include repository sources"); + }); + + it("fails when a repository-relative source has empty content", () => { + expect(() => + validateSourcemap( + harness({ + [BUNDLE]: VALID_BUNDLE, + [MAP]: validMap({ + sources: ["../src/server.ts", "../src/other.ts"], + sourcesContent: ["export function start() {}\n", " "], + }), + }), + ), + ).toThrow("dist/server.mjs.map is missing source content for a repository source"); + }); +});