Files
AutistMask/tests/packaging.test.js
clawbot 45500e66cf
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 32s
fix: declare and ship toolbar icons, so neither browser renders a puzzle piece (closes #371)
Neither manifest declared any icons, so both browsers showed a generic puzzle-piece -- the first thing seen on every browser start, and how a user tells a real extension from a look-alike. Both manifests now declare 16/32/48/128, and the PNGs ship inside each browser archive rather than being left at dist/ root, which is the trap that made a naive zip incomplete before.

build.js reads which icons to copy from each manifest's own icons block, so the manifest is the single source of truth and a declared-but-absent size fails the build rather than shipping a dangling reference; the packager's reference-resolver covers them independently. Manifest values are constrained before being joined into a path. The artwork is original, generated from geometry rather than traced or fetched.
2026-08-23 21:26:14 +02:00

211 lines
8.4 KiB
JavaScript

// The release archives: the zip container, and the self-containment rule.
//
// script/package produces one archive per browser and script/lib/package.js
// decides what goes in it. The trap that rule exists for is real and specific:
// build.js writes the compiled stylesheet to dist/styles.css at the dist/
// ROOT, outside both browser directories, and copies it into each of them as
// src/popup/styles.css. A `zip -r dist/chrome` is correct only because of that
// copy, and would silently start shipping a popup with no stylesheet the
// moment a reference pointed up and out of the directory.
//
// So the packager resolves every reference in the manifest and in every HTML
// document, and fails on any that leaves the extension root. These are the
// cases for that, plus the archive format itself — new code, and the thing the
// artifact is made of.
const {
checkSelfContained,
htmlReferences,
manifestReferences,
} = require("../script/lib/package");
const { readZip, writeZip } = require("../script/lib/zip");
function archiveOf(files) {
return {
members: Object.keys(files).sort(),
read: (name) => Buffer.from(files[name] ?? "", "utf8"),
};
}
const MINIMAL_MANIFEST = {
manifest_version: 3,
name: "AutistMask",
version: "0.1.0",
action: { default_popup: "src/popup/index.html" },
background: { service_worker: "src/background/index.js" },
};
describe("archive self-containment", () => {
test("a complete tree passes", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html":
'<link rel="stylesheet" href="styles.css" />' +
'<script src="index.js"></script>',
"src/popup/styles.css": "body{}",
"src/popup/index.js": "//",
"src/background/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).not.toThrow();
});
test("a manifest naming a file that is not in the archive fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html": "<html></html>",
"src/popup/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/would not be self-contained.*src\/background\/index\.js/s,
);
});
// The dist/styles.css case, exactly: a popup that reached up out of its
// own browser directory for the stylesheet the build leaves at the dist/
// root. Nothing would be missing from disk, and the zip would still be
// built — the archive would just have no stylesheet in it.
test("an HTML reference that escapes the extension root fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html":
'<link rel="stylesheet" href="../../../styles.css" />',
"src/popup/index.js": "//",
"src/background/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/resolves outside the extension root/,
);
});
test("a manifest reference that escapes the extension root fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify({
...MINIMAL_MANIFEST,
background: { service_worker: "../shared/index.js" },
}),
"src/popup/index.html": "<html></html>",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/points outside the extension root/,
);
});
// Toolbar icons are the other file the manifest names and no bundler
// emits, so an archive built without them would carry a manifest whose
// "icons" resolve to nothing and a browser would fall back to a generic
// placeholder without saying so.
test("a manifest naming an icon that is not in the archive fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify({
...MINIMAL_MANIFEST,
icons: { 16: "icons/icon16.png", 128: "icons/icon128.png" },
}),
"src/popup/index.html": "<html></html>",
"src/popup/index.js": "//",
"src/background/index.js": "//",
"icons/icon16.png": "PNG",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/would not be self-contained.*icons\/icon128\.png/s,
);
});
test("an archive with no manifest.json at its root fails", () => {
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/no manifest\.json at its root/,
);
});
test("manifest strings that are not paths are not treated as files", () => {
const found = manifestReferences({
name: "AutistMask",
version: "0.1.0",
permissions: ["storage", "<all_urls>"],
content_security_policy: {
extension_pages: "default-src 'self'; script-src 'self'",
},
key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy",
});
expect([...found]).toEqual([]);
});
test("absolute, data: and anchor HTML references are not files", () => {
const refs = htmlReferences(
"src/popup/index.html",
'<a href="https://example.com/x.js">a</a>' +
'<img src="data:image/png;base64,AAAA" />' +
'<a href="#view-main">b</a>' +
'<script src="index.js"></script>',
);
expect([...refs]).toEqual(["src/popup/index.js"]);
});
});
describe("the zip container", () => {
const files = [
{ name: "manifest.json", data: Buffer.from('{"a":1}') },
// Long enough that deflate wins, so both paths through compress() are
// exercised by one archive.
{ name: "src/popup/index.js", data: Buffer.from("x".repeat(5000)) },
{ name: "empty.txt", data: Buffer.alloc(0) },
];
test("round-trips every member byte for byte", () => {
const entries = readZip(writeZip(files));
expect(entries.map((e) => e.name)).toEqual([
"empty.txt",
"manifest.json",
"src/popup/index.js",
]);
for (const original of files) {
const found = entries.find((e) => e.name === original.name);
expect(found.data.equals(original.data)).toBe(true);
}
});
// The sha256 in release/SHA256SUMS has to be a property of the input. Two
// builds of one commit that produce different archives cannot be compared
// to each other, which is most of what publishing a digest is for.
test("is byte-identical across runs and independent of input order", () => {
const a = writeZip(files);
const b = writeZip([...files].reverse());
expect(a.equals(b)).toBe(true);
});
// Reading an archive back is how script/lib/package.js establishes that
// the artifact holds what dist/ holds, so a member whose bytes changed
// after it was written has to fail rather than be handed back.
test("a corrupted member fails its CRC on read", () => {
// One member, incompressible at this size, so it is stored verbatim
// and its bytes begin at a known offset: local header (30) + name.
const name = "a.js";
const archive = writeZip([{ name, data: Buffer.from("hello") }]);
expect(readZip(archive)[0].data.toString()).toBe("hello");
archive[30 + name.length] ^= 0xff;
expect(() => readZip(archive)).toThrow(/fails its recorded CRC32/);
});
test.each([["/abs.js"], ["../up.js"], ["a/../b.js"], ["a\\b.js"], [""]])(
"refuses the member name %p",
(name) => {
expect(() => writeZip([{ name, data: Buffer.from("x") }])).toThrow(
/member name/,
);
},
);
test("refuses an archive with no members", () => {
expect(() => writeZip([])).toThrow(/no members/);
});
test("refuses duplicate members", () => {
expect(() =>
writeZip([
{ name: "a.js", data: Buffer.from("1") },
{ name: "a.js", data: Buffer.from("2") },
]),
).toThrow(/duplicate member/);
});
});