Files
HMDesign/packages/sw/tests/build-reproducibility.mjs
T
shkim4andClaude Opus 5.5 55179b4577 feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)
SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).

- Build reproducibility: dist text normalized to LF, dist/build-info.json
  (commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
  (eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
  the logo in the existing document-button look, reusing onDocumentOpen and
  documentOpenLabel, no duplicate Open in the dropdown), leading slot,
  hiddenControls sub-items documentNew / documentOpen / layerFreeze /
  layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
  menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
  reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
  SWHeaderPropertyPanel gains children, title, autoFocus and optional
  selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
  (useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
  SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
  overview 52/54 with the two pre-existing historical assertions).

Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:49:19 +09:00

99 lines
5.7 KiB
JavaScript

// Build reproducibility of @hm-design/sw (2026-10-19).
// Copies the current packages/sw source (+ root tsconfig.app.json it extends) into three directories
// outside the repository — two LF at different depths and one CRLF copy (as made outside git) — links
// the repository node_modules (same locked tools), builds each twice and packs it.
// dist must be byte-identical everywhere; the tarball must be identical for every LF checkout.
// Usage: node packages/sw/tests/build-reproducibility.mjs <empty-dir-outside-the-repo> <evidence-dir>
import { execFileSync } from "node:child_process"
import { createHash } from "node:crypto"
import { cp, mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
const repoRoot = path.resolve(packageRoot, "../..")
const outside = dir => { const relative = path.relative(repoRoot, dir); return relative.startsWith("..") || path.isAbsolute(relative) }
const work = path.resolve(process.argv[2] ?? "")
const evidence = path.resolve(process.argv[3] ?? "")
if (!process.argv[2] || !process.argv[3] || !outside(work)) throw new Error("Pass <empty dir outside the repo> <evidence dir>")
await mkdir(work, { recursive: true })
if ((await readdir(work)).length) throw new Error(`${work} is not empty`)
await mkdir(evidence, { recursive: true })
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim()
const env = { ...process.env, SW_BUILD_COMMIT: `${commit}+reproducibility-check` }
const sha = data => createHash("sha256").update(data).digest("hex")
// SVG assets are excluded: they are inlined as base64, so their bytes are fixed at checkout by
// packages/sw/.gitattributes (eol=lf) and checked separately with `git ls-files --eol`.
const textFile = name => /\.(tsx?|css|mjs|json|md)$/.test(name)
async function copySource(target, crlf) {
const sw = path.join(target, "packages/sw")
for (const entry of ["src", "ai", "README.md", "build.mjs", "package.json", "tsconfig.json", "tsconfig.build.json"]) {
await cp(path.join(packageRoot, entry), path.join(sw, entry), { recursive: true })
}
await cp(path.join(repoRoot, "tsconfig.app.json"), path.join(target, "tsconfig.app.json"))
if (crlf) {
const convert = async directory => {
for (const item of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, item.name)
if (item.isDirectory()) await convert(file)
else if (textFile(item.name)) await writeFile(file, (await readFile(file, "utf8")).replace(/\r?\n/g, "\r\n"))
}
}
await convert(sw)
}
// Same locked tools: a directory junction to the repository node_modules (removed with rmdir semantics below).
await symlink(path.join(repoRoot, "node_modules"), path.join(target, "node_modules"), "junction")
return sw
}
async function hashes(sw) {
const out = {}
const walk = async directory => {
for (const item of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, item.name)
if (item.isDirectory()) await walk(file)
else out[path.relative(sw, file).replaceAll("\\", "/")] = sha(await readFile(file))
}
}
await walk(path.join(sw, "dist"))
return out
}
const npm = process.platform === "win32" ? "npm.cmd" : "npm"
const runs = []
const layouts = [["lf", false], ["deeper/path/lf", false], ["crlf-copy", true]]
for (const [name, crlf] of layouts) {
const target = path.join(work, name)
const sw = await copySource(target, crlf)
for (const attempt of [1, 2]) {
execFileSync(process.execPath, ["build.mjs"], { cwd: sw, env, stdio: "pipe" })
const files = await hashes(sw)
const packDir = path.join(target, `pack-${attempt}`)
await mkdir(packDir)
execFileSync(npm, ["pack", "--ignore-scripts", "--pack-destination", packDir], { cwd: sw, env, stdio: "pipe", shell: process.platform === "win32" })
const tarball = (await readdir(packDir)).find(file => file.endsWith(".tgz"))
runs.push({ checkout: crlf ? "CRLF" : "LF", path: target, attempt, tarball, tarballSha256: sha(await readFile(path.join(packDir, tarball))), files })
}
}
// Leave the junctions behind as junctions only (never recurse into the repository node_modules).
for (const [name] of layouts) await rm(path.join(work, name, "node_modules"), { force: true })
const reference = runs[0]
const differences = runs.slice(1).map(run => ({
run: `${run.checkout} #${run.attempt}`,
tarballSame: run.tarballSha256 === reference.tarballSha256,
differentFiles: Object.keys({ ...reference.files, ...run.files }).filter(file => reference.files[file] !== run.files[file]),
}))
// Index and working tree of every package file must be LF (or binary) so a fresh checkout matches.
const eol = execFileSync("git", ["ls-files", "--eol", "--", "."], { cwd: packageRoot, encoding: "utf8" }).trim().split("\n")
const notLf = eol.filter(line => !/^i\/(lf|-text)\s+w\/(lf|-text)\s/.test(line))
// dist must match everywhere; the tarball also ships ai/ and README.md verbatim, so it must match for LF
// checkouts (guaranteed by .gitattributes). A CRLF copy made outside git only has to keep dist identical.
const pass = differences.every(item => item.differentFiles.length === 0 && (item.run.startsWith("CRLF") || item.tarballSame)) && notLf.length === 0
const report = { status: pass ? "PASS" : "FAIL", commit, notLf, buildCommit: env.SW_BUILD_COMMIT, runs: runs.map(({ files, ...run }) => ({ ...run, fileCount: Object.keys(files).length })), referenceFiles: reference.files, differences }
await writeFile(path.join(evidence, "build-reproducibility-result.json"), JSON.stringify(report, null, 2) + "\n")
console.log(report.status, JSON.stringify(differences))
if (!pass) process.exitCode = 1