// 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;
});
