release: produce a versioned per-browser artifact and pin the Chrome extension id (closes #310)
manifest/chrome.json now carries a fixed public key, so the extension id and the chrome.storage.local partition holding the wallet stay stable across checkout moves and re-clones instead of being derived from the absolute path. A release entrypoint produces a self-contained versioned artifact per browser, including the files that sit at dist/ root outside both browser directories. One version source of truth, enforced: the build fails naming the culprit when the two manifests and package.json disagree, and BUILD_COMMIT now marks a dirty tree as dirty. Firefox ships an unsigned XPI; the README states that release Firefox and ESR refuse it, that Developer Edition or Unbranded is required, and that Remove is irreversible except from the recovery phrase, which is asserted by test.
This commit was merged in pull request #347.
This commit is contained in:
303
tests/e2e/storagePartition.js
Normal file
303
tests/e2e/storagePartition.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// Where does chrome.storage.local live, and what moves it?
|
||||
//
|
||||
// The finding behind https://git.eeqj.de/sneak/AutistMask/issues/310: an
|
||||
// unpacked Chrome extension with no `key` in its manifest gets an extension id
|
||||
// derived from the ABSOLUTE PATH it was loaded from, and chrome.storage.local
|
||||
// is partitioned by that id. README.md documents Load unpacked from
|
||||
// dist/chrome/ as the install route, so moving the checkout, re-cloning it, or
|
||||
// loading a second copy from anywhere else means a different id, a different
|
||||
// storage partition, and a wallet that reads as empty — with no error, no
|
||||
// prompt and nothing in the UI to say what happened.
|
||||
//
|
||||
// So this OBSERVES the behaviour rather than restating it. Two unpacked loads
|
||||
// from two different directories, in one profile, with the shipped manifest
|
||||
// and again with `key` stripped out, and it reports what each pair actually
|
||||
// does. The assertions are on what was seen when this was written and are
|
||||
// annotated as such; if Chrome's derivation ever changes, this says so instead
|
||||
// of passing.
|
||||
//
|
||||
// Run through script/test-e2e, which builds the extension and the pinned
|
||||
// container.
|
||||
//
|
||||
// node tests/e2e/storagePartition.js
|
||||
//
|
||||
// A separate program from run.js because every test there shares one browser
|
||||
// and one extension load, and the whole subject here is what happens across
|
||||
// two of each.
|
||||
//
|
||||
// The extension's own UI is deliberately not driven. What is under test is the
|
||||
// storage partition, so a sentinel key the extension never reads or writes is
|
||||
// written and read back directly: a wallet would prove the same thing more
|
||||
// slowly, and would confuse an empty partition with a UI that failed to
|
||||
// render.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const { chromium } = require("playwright-core");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const DIST_CHROME = path.join(REPO_ROOT, "dist", "chrome");
|
||||
|
||||
// Never touched by the extension: src/shared/state.js reads and writes the
|
||||
// single key "autistmask" and nothing else.
|
||||
const SENTINEL_KEY = "e2e-storage-partition-sentinel";
|
||||
const SENTINEL_VALUE = "written-by-the-first-load";
|
||||
|
||||
const checks = [];
|
||||
let failed = 0;
|
||||
const scratch = [];
|
||||
|
||||
function check(name, cond, detail) {
|
||||
checks.push(name);
|
||||
if (cond) {
|
||||
console.log("ok " + checks.length + " - " + name);
|
||||
} else {
|
||||
failed += 1;
|
||||
console.log("not ok " + checks.length + " - " + name);
|
||||
if (detail) console.log(" " + detail);
|
||||
}
|
||||
}
|
||||
|
||||
function tmpdir(tag) {
|
||||
const dir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "autistmask-" + tag + "-"),
|
||||
);
|
||||
scratch.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// A copy of the built extension at a fresh absolute path. `withKey: false`
|
||||
// strips the manifest's `key`, which is the pre-fix state of this repo and the
|
||||
// control the whole program is built around.
|
||||
function extensionCopy(tag, withKey) {
|
||||
const dir = path.join(tmpdir(tag), "chrome");
|
||||
fs.cpSync(DIST_CHROME, dir, { recursive: true });
|
||||
const manifestPath = path.join(dir, "manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
if (withKey) {
|
||||
if (!manifest.key) {
|
||||
throw new Error(
|
||||
"dist/chrome/manifest.json has no `key`, so this program has " +
|
||||
"nothing to observe. That field is what pins the " +
|
||||
"extension id; see tests/extensionId.test.js.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
delete manifest.key;
|
||||
}
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 4));
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function launch(profileDir, extensionDirs) {
|
||||
const ctx = await chromium.launchPersistentContext(profileDir, {
|
||||
// See the note in harness.js: the default headless shell silently
|
||||
// refuses to load extensions.
|
||||
channel: "chromium",
|
||||
headless: true,
|
||||
args: [
|
||||
"--disable-extensions-except=" + extensionDirs.join(","),
|
||||
"--load-extension=" + extensionDirs.join(","),
|
||||
"--no-sandbox",
|
||||
// Nothing here should reach the network; this makes sure it
|
||||
// cannot.
|
||||
"--host-resolver-rules=MAP * ~NOTFOUND",
|
||||
],
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Every extension id Chrome ended up with in this context, from the service
|
||||
// worker urls. MV3 registers one worker per loaded extension.
|
||||
async function extensionIds(ctx, expected) {
|
||||
const deadline = Date.now() + 30000;
|
||||
for (;;) {
|
||||
const ids = [
|
||||
...new Set(ctx.serviceWorkers().map((w) => new URL(w.url()).host)),
|
||||
].sort();
|
||||
if (ids.length >= expected) return ids;
|
||||
if (Date.now() > deadline) return ids;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
|
||||
// Open one extension's popup and run `fn` in it. The popup page is used rather
|
||||
// than the service worker because Chrome stops an idle MV3 worker, and a
|
||||
// handle to a stopped worker cannot be evaluated in.
|
||||
async function inExtension(ctx, id, fn, arg) {
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto("chrome-extension://" + id + "/src/popup/index.html");
|
||||
return await page.evaluate(fn, arg);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
const writeSentinel = ([key, value]) =>
|
||||
new Promise((resolve) => {
|
||||
chrome.storage.local.set({ [key]: value }, () => resolve(true));
|
||||
});
|
||||
|
||||
const readSentinel = (key) =>
|
||||
new Promise((resolve) => {
|
||||
chrome.storage.local.get(key, (r) => resolve(r[key] ?? null));
|
||||
});
|
||||
|
||||
// Load `firstDir` in a fresh profile, write the sentinel, close; load
|
||||
// `secondDir` in the SAME profile, read it back. Returns both ids and what the
|
||||
// second load saw.
|
||||
async function acrossTwoPaths(tag, firstDir, secondDir) {
|
||||
const profile = tmpdir(tag + "-profile");
|
||||
|
||||
const first = await launch(profile, [firstDir]);
|
||||
let firstId;
|
||||
try {
|
||||
[firstId] = await extensionIds(first, 1);
|
||||
if (!firstId) throw new Error("no extension loaded from " + firstDir);
|
||||
await inExtension(first, firstId, writeSentinel, [
|
||||
SENTINEL_KEY,
|
||||
SENTINEL_VALUE,
|
||||
]);
|
||||
} finally {
|
||||
await first.close();
|
||||
}
|
||||
|
||||
const second = await launch(profile, [secondDir]);
|
||||
let secondId;
|
||||
let seen;
|
||||
try {
|
||||
[secondId] = await extensionIds(second, 1);
|
||||
if (!secondId) throw new Error("no extension loaded from " + secondDir);
|
||||
seen = await inExtension(second, secondId, readSentinel, SENTINEL_KEY);
|
||||
} finally {
|
||||
await second.close();
|
||||
}
|
||||
|
||||
return { firstId, secondId, seen };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) {
|
||||
console.error(
|
||||
"storagePartition: no unpacked build at " +
|
||||
DIST_CHROME +
|
||||
" — run make build first",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// --- the shipped manifest, which carries `key` ----------------------
|
||||
const keyedA = extensionCopy("keyed-a", true);
|
||||
const keyedB = extensionCopy("keyed-b", true);
|
||||
const keyed = await acrossTwoPaths("keyed", keyedA, keyedB);
|
||||
console.log("# with `key`: " + keyedA + " -> " + keyed.firstId);
|
||||
console.log("# with `key`: " + keyedB + " -> " + keyed.secondId);
|
||||
console.log("# with `key`: sentinel read back: " + keyed.seen);
|
||||
|
||||
check(
|
||||
"with `key`, two different paths produce the SAME extension id",
|
||||
keyed.firstId === keyed.secondId,
|
||||
keyed.firstId + " != " + keyed.secondId,
|
||||
);
|
||||
check(
|
||||
"with `key`, the second path reads the first path's storage",
|
||||
keyed.seen === SENTINEL_VALUE,
|
||||
"the second load read " +
|
||||
JSON.stringify(keyed.seen) +
|
||||
" instead of the value the first load wrote. The storage " +
|
||||
"partition did not follow the extension across the move, " +
|
||||
"which is the wallet silently reading as empty.",
|
||||
);
|
||||
|
||||
// --- the same build with `key` removed: the control -----------------
|
||||
const barePath = extensionCopy("bare-a", false);
|
||||
const bareB = extensionCopy("bare-b", false);
|
||||
const bare = await acrossTwoPaths("bare", barePath, bareB);
|
||||
console.log("# without `key`: " + barePath + " -> " + bare.firstId);
|
||||
console.log("# without `key`: " + bareB + " -> " + bare.secondId);
|
||||
console.log("# without `key`: sentinel read back: " + bare.seen);
|
||||
|
||||
// Observed, not assumed. If Chrome ever stops deriving the id from
|
||||
// the load path these two fail, and the right response is to record
|
||||
// what it does now — not to delete them.
|
||||
check(
|
||||
"without `key`, two different paths produce DIFFERENT ids " +
|
||||
"(observed Chrome behaviour, the defect this fixes)",
|
||||
bare.firstId !== bare.secondId,
|
||||
"both loads got " +
|
||||
bare.firstId +
|
||||
", so the id no longer follows the load path on this Chrome",
|
||||
);
|
||||
check(
|
||||
"without `key`, the second path sees an EMPTY partition " +
|
||||
"(observed Chrome behaviour, the defect this fixes)",
|
||||
bare.seen === null,
|
||||
"the second load read " +
|
||||
JSON.stringify(bare.seen) +
|
||||
" from a different id's partition",
|
||||
);
|
||||
|
||||
// --- both copies loaded at once, in one profile ---------------------
|
||||
// The literal shape of the question, kept because "two unpacked loads
|
||||
// in one profile" is what a user does when they forget to remove the
|
||||
// old one. With `key`, both copies claim the same id.
|
||||
const profile = tmpdir("simultaneous-profile");
|
||||
const ctx = await launch(profile, [keyedA, keyedB]);
|
||||
let ids = [];
|
||||
try {
|
||||
ids = await extensionIds(ctx, 2);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
console.log(
|
||||
"# both keyed copies loaded at once: " +
|
||||
ids.length +
|
||||
" extension id(s): " +
|
||||
ids.join(", "),
|
||||
);
|
||||
check(
|
||||
"loading both keyed copies at once yields one id, not two " +
|
||||
"(observed: Chrome does not load a second copy of an id it " +
|
||||
"already has)",
|
||||
ids.length === 1 && ids[0] === keyed.firstId,
|
||||
"saw " + JSON.stringify(ids) + ", expected exactly one id",
|
||||
);
|
||||
} catch (e) {
|
||||
failed += 1;
|
||||
console.log("# ERROR: " + (e && e.stack ? e.stack : e));
|
||||
} finally {
|
||||
for (const dir of scratch) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log("1.." + checks.length);
|
||||
if (checks.length === 0) {
|
||||
console.log("# FAILED: this program asserted nothing");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
"# " +
|
||||
(checks.length - failed) +
|
||||
"/" +
|
||||
checks.length +
|
||||
" checks passed",
|
||||
);
|
||||
if (failed > 0) {
|
||||
console.log("# FAILED");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("storagePartition: " + (e && e.stack ? e.stack : e));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user