diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index b075a66..649f2aa 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release unsigned Firefox extension +name: Release Firefox and Chromium extensions on: release: @@ -11,7 +11,7 @@ permissions: jobs: package-and-attach: - name: Package and attach unsigned XPI + name: Package and attach browser extensions runs-on: ubuntu-latest timeout-minutes: 10 @@ -30,14 +30,14 @@ jobs: - name: Run automated checks run: npm test - - name: Build unsigned release archive + - name: Build unsigned release archives env: GITEA_RELEASE_TAG: ${{ gitea.event.release.tag_name }} run: npm run package -- --version "${GITEA_RELEASE_TAG}" --output-dir dist - name: Verify release checksum working-directory: dist - run: sha256sum --check *.xpi.sha256 + run: sha256sum --check *.sha256 - name: Attach release assets env: @@ -47,7 +47,7 @@ jobs: GITEA_RELEASE_ID: ${{ gitea.event.release.id }} run: | set -eu - for asset in dist/*.xpi dist/*.xpi.sha256; do + for asset in dist/*.xpi dist/*.xpi.sha256 dist/*-chromium.zip dist/*-chromium.zip.sha256; do name="$(basename "${asset}")" curl --fail-with-body --silent --show-error \ --request POST \ diff --git a/README.md b/README.md index 28fc926..9dbd3f2 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # Glagolify -Glagolify is a small Firefox extension that turns Russian Cyrillic text on webpages into Glagolitic. Switch it off and the original text comes back—no reload required. +Glagolify is a small Firefox and Chromium extension that turns Russian Cyrillic text on webpages into Glagolitic. Switch it off and the original text comes back—no reload required. Choose from four letter styles: **Round**, **Angular Sans**, **Historical Angular**, and **Missal Angular**. Glagolify also handles text added after a page loads while leaving text fields and editors alone. ## Install +### Firefox + Glagolify requires Firefox 142 or newer. 1. Download the latest `.xpi` from [Releases](https://git.krtss.de/StanPonomarev/glagolify/releases). @@ -15,8 +17,33 @@ Glagolify requires Firefox 142 or newer. Release builds are currently unsigned, so regular Firefox installs them temporarily and removes them when Firefox restarts. +### Chromium + +Glagolify requires Chromium 102 or newer. + +1. Download and extract the latest `-chromium.zip` from [Releases](https://git.krtss.de/StanPonomarev/glagolify/releases). +2. Open `chrome://extensions` in Chromium and enable **Developer mode**. +3. Select **Load unpacked** and choose the extracted directory. + ## Use Open a webpage, click the Glagolify toolbar icon, choose a style, and select **Enable Glagolitic**. Select **Restore Cyrillic** when you want the original text back. -Glagolify transliterates letters; it does not translate language. Firefox settings pages, browser UI, and other protected pages cannot be changed. +Glagolify transliterates letters; it does not translate language. Browser settings pages, browser UI, and other protected pages cannot be changed. + +## Build + +Node.js 22 or newer is required. The build has no third-party dependencies. + +```sh +npm test +npm run package +``` + +`npm run package` creates both `dist/glagolify-.xpi` for Firefox and `dist/glagolify--chromium.zip`, with a `.sha256` file beside each archive. Use `npm run package:firefox` or `npm run package:chromium` to build only one target. A release version and output directory can be supplied to any command, for example: + +```sh +npm run package:chromium -- --version v1.2.3 --output-dir dist +``` + +The checked-in manifest remains the Firefox Manifest V2 source. The Chromium package command converts it to Manifest V3 and uses the shared runtime files. diff --git a/background.js b/background.js index 6d0118a..3b2514e 100644 --- a/background.js +++ b/background.js @@ -2,6 +2,7 @@ "use strict"; const FONT_STYLE_KEY = "glagolifyFontStyle"; + const TAB_STATES_KEY = "glagolifyTabStates"; const FONT_STYLES = new Set(["round", "angular-sans", "angular", "missal-angular"]); const DEFAULT_FONT_STYLE = "round"; @@ -13,14 +14,35 @@ if (stored && FONT_STYLES.has(stored[FONT_STYLE_KEY])) { fontStyle = stored[FONT_STYLE_KEY]; } - return fontStyle; }) - .catch(() => fontStyle); + .catch(() => undefined); + const tabStatesReady = Promise.resolve(api.storage.session.get(TAB_STATES_KEY)) + .then((stored) => { + const states = stored && stored[TAB_STATES_KEY]; + if (!states || typeof states !== "object" || Array.isArray(states)) { + return; + } + + for (const [storedTabId, enabled] of Object.entries(states)) { + const tabId = Number(storedTabId); + if (validTabId(tabId) && typeof enabled === "boolean") { + tabStates.set(tabId, enabled); + } + } + }) + .catch(() => undefined); + const controllerReady = Promise.all([fontStyleReady, tabStatesReady]); function validTabId(tabId) { return Number.isInteger(tabId) && tabId >= 0; } + async function persistTabStates() { + await api.storage.session.set({ + [TAB_STATES_KEY]: Object.fromEntries(tabStates) + }); + } + async function broadcast(tabId, enabled) { await fontStyleReady; try { @@ -35,14 +57,14 @@ } } - api.runtime.onMessage.addListener((message, sender) => { + function handleMessage(message, sender) { if (!message || typeof message.type !== "string") { return undefined; } if (message.type === "glagolify:document-ready") { const tabId = sender && sender.tab && sender.tab.id; - return fontStyleReady.then(() => ({ + return controllerReady.then(() => ({ enabled: validTabId(tabId) && tabStates.get(tabId) === true, fontStyle })); @@ -50,7 +72,7 @@ if (message.type === "glagolify:get-tab-state") { const tabId = message.tabId; - return fontStyleReady.then(async () => { + return controllerReady.then(async () => { if (!validTabId(tabId)) { return { enabled: false, available: false, fontStyle }; } @@ -64,15 +86,16 @@ if (message.type === "glagolify:set-tab-enabled") { const tabId = message.tabId; if (!validTabId(tabId) || typeof message.enabled !== "boolean") { - return fontStyleReady.then(() => ({ + return controllerReady.then(() => ({ enabled: false, available: false, fontStyle })); } - return fontStyleReady.then(async () => { + return controllerReady.then(async () => { tabStates.set(tabId, message.enabled); + await persistTabStates(); const available = await broadcast(tabId, message.enabled); return { enabled: message.enabled, @@ -84,10 +107,10 @@ if (message.type === "glagolify:set-font-style") { if (!FONT_STYLES.has(message.fontStyle)) { - return fontStyleReady.then(() => ({ fontStyle, accepted: false })); + return controllerReady.then(() => ({ fontStyle, accepted: false })); } - return fontStyleReady.then(async () => { + return controllerReady.then(async () => { await api.storage.local.set({ [FONT_STYLE_KEY]: message.fontStyle }); fontStyle = message.fontStyle; await Promise.all( @@ -100,24 +123,46 @@ } return undefined; + } + + api.runtime.onMessage.addListener((message, sender, sendResponse) => { + const response = handleMessage(message, sender); + if (response === undefined) { + return false; + } + + Promise.resolve(response).then( + (value) => sendResponse(value), + () => sendResponse(undefined) + ); + return true; }); - api.tabs.onRemoved.addListener((tabId) => { - tabStates.delete(tabId); + api.tabs.onRemoved.addListener(async (tabId) => { + await tabStatesReady; + if (tabStates.delete(tabId)) { + await persistTabStates(); + } }); return { tabStates, broadcast, - fontStyleReady, + controllerReady, getFontStyle: () => fontStyle }; } const exported = Object.freeze({ createBackgroundController }); - if (typeof browser === "object" && browser.runtime && browser.tabs) { - createBackgroundController(browser); + const extensionApi = + typeof browser === "object" + ? browser + : typeof chrome === "object" + ? chrome + : null; + if (extensionApi && extensionApi.runtime && extensionApi.tabs) { + createBackgroundController(extensionApi); } if (typeof module === "object" && module.exports) { diff --git a/content.js b/content.js index e81ded9..8ee4e7c 100644 --- a/content.js +++ b/content.js @@ -1,6 +1,8 @@ (function () { "use strict"; + const api = typeof browser === "object" ? browser : chrome; + const EXCLUDED_ANCESTORS = "head, script, style, noscript, template, textarea, select, option"; const FONT_FAMILIES = Object.freeze({ round: '"Glagolify Shafarik Round"', @@ -330,14 +332,15 @@ return { enabled }; } - browser.runtime.onMessage.addListener((message) => { + api.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message || message.type !== "glagolify:set-enabled" || typeof message.enabled !== "boolean") { - return undefined; + return false; } - return Promise.resolve(setEnabled(message.enabled, message.fontStyle)); + sendResponse(setEnabled(message.enabled, message.fontStyle)); + return false; }); - browser.runtime.sendMessage({ type: "glagolify:document-ready" }) + api.runtime.sendMessage({ type: "glagolify:document-ready" }) .then((response) => { setEnabled(Boolean(response && response.enabled), response && response.fontStyle); }) diff --git a/icons/icon-128.png b/icons/icon-128.png new file mode 100644 index 0000000..3bfc3e5 Binary files /dev/null and b/icons/icon-128.png differ diff --git a/icons/icon-16.png b/icons/icon-16.png new file mode 100644 index 0000000..47146ee Binary files /dev/null and b/icons/icon-16.png differ diff --git a/icons/icon-32.png b/icons/icon-32.png new file mode 100644 index 0000000..81bae36 Binary files /dev/null and b/icons/icon-32.png differ diff --git a/icons/icon-48.png b/icons/icon-48.png new file mode 100644 index 0000000..b6c3eb7 Binary files /dev/null and b/icons/icon-48.png differ diff --git a/icons/icon-96.png b/icons/icon-96.png new file mode 100644 index 0000000..06a93f8 Binary files /dev/null and b/icons/icon-96.png differ diff --git a/package.json b/package.json index d14ac95..c92ac77 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { - "name": "glagolify-firefox-extension", + "name": "glagolify-extension", "version": "1.1.0", "private": true, - "description": "A dependency-free Firefox extension for toggling modern Russian text to Glagolitic.", + "description": "A dependency-free Firefox and Chromium extension for toggling modern Russian text to Glagolitic.", "scripts": { "package": "node scripts/package-extension.js", + "package:firefox": "node scripts/package-extension.js --target firefox", + "package:chromium": "node scripts/package-extension.js --target chromium", "test": "node --test" } } diff --git a/popup.js b/popup.js index 5bb96a5..e757702 100644 --- a/popup.js +++ b/popup.js @@ -1,6 +1,8 @@ (function () { "use strict"; + const api = typeof browser === "object" ? browser : chrome; + const status = document.getElementById("status"); const toggle = document.getElementById("toggle"); const fontStyleInputs = [...document.querySelectorAll('input[name="font-style"]')]; @@ -39,11 +41,11 @@ toggle.disabled = true; toggle.setAttribute("aria-pressed", "false"); toggle.textContent = "Unavailable on this page"; - status.textContent = "Firefox protects this page from extensions."; + status.textContent = "The browser protects this page from extensions."; } async function askBackground(type, extra = {}) { - return browser.runtime.sendMessage({ type, ...extra }); + return api.runtime.sendMessage({ type, ...extra }); } toggle.addEventListener("click", async () => { @@ -104,7 +106,7 @@ async function initialize() { try { - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tabs = await api.tabs.query({ active: true, currentWindow: true }); if (!tabs[0] || typeof tabs[0].id !== "number") { showUnavailable(); return; diff --git a/scripts/package-extension.js b/scripts/package-extension.js index ade3463..37b6d44 100644 --- a/scripts/package-extension.js +++ b/scripts/package-extension.js @@ -5,7 +5,7 @@ const { mkdir, readFile, writeFile } = require("node:fs/promises"); const path = require("node:path"); const { deflateRawSync } = require("node:zlib"); -const ARCHIVE_FILES = Object.freeze([ +const FIREFOX_ARCHIVE_FILES = Object.freeze([ "background.js", "content.css", "content.js", @@ -23,6 +23,14 @@ const ARCHIVE_FILES = Object.freeze([ "popup.js", "transliterate.js" ]); +const CHROMIUM_ARCHIVE_FILES = Object.freeze([ + ...FIREFOX_ARCHIVE_FILES.map((relativePath) => + relativePath.endsWith(".svg") ? relativePath.replace(/\.svg$/, ".png") : relativePath + ), + "icons/icon-128.png", + "icons/icon-48.svg" +]); +const TARGETS = new Set(["firefox", "chromium"]); const VERSION_PATTERN = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/; const UTF8_FLAG = 0x0800; @@ -46,6 +54,47 @@ function normalizeVersion(value) { return version; } +function createManifest(contents, version, target) { + const manifest = JSON.parse(contents.toString("utf8")); + manifest.version = version; + + if (target === "firefox") { + return manifest; + } + + if (version.split(".").some((part) => Number(part) > 65535)) { + throw new Error(`Chromium extension version parts cannot exceed 65535: ${version}`); + } + + manifest.manifest_version = 3; + manifest.minimum_chrome_version = "102"; + delete manifest.browser_specific_settings; + manifest.host_permissions = manifest.permissions.filter( + (permission) => permission === "" || permission.includes("://") + ); + manifest.permissions = manifest.permissions.filter( + (permission) => !manifest.host_permissions.includes(permission) + ); + manifest.background = { service_worker: "background.js" }; + manifest.action = manifest.browser_action; + delete manifest.browser_action; + manifest.web_accessible_resources = [ + { + resources: manifest.web_accessible_resources, + matches: [""] + } + ]; + + for (const icons of [manifest.icons, manifest.action.default_icon]) { + for (const size of Object.keys(icons)) { + icons[size] = icons[size].replace(/\.svg$/, ".png"); + } + } + manifest.icons["128"] = "icons/icon-128.png"; + + return manifest; +} + function crc32(buffer) { let crc = 0xffffffff; @@ -122,19 +171,29 @@ function createZip(entries) { return Buffer.concat([...localRecords, centralDirectory, endRecord]); } -async function packageExtension({ rootDirectory, outputDirectory, version: requestedVersion }) { +async function packageExtension({ + rootDirectory, + outputDirectory, + target = "firefox", + version: requestedVersion +}) { + if (!TARGETS.has(target)) { + throw new Error(`Unknown extension target: ${target}`); + } + const packageMetadata = JSON.parse( await readFile(path.join(rootDirectory, "package.json"), "utf8") ); const version = normalizeVersion(requestedVersion ?? packageMetadata.version); + const archiveFiles = + target === "firefox" ? FIREFOX_ARCHIVE_FILES : CHROMIUM_ARCHIVE_FILES; const entries = []; - for (const relativePath of ARCHIVE_FILES) { + for (const relativePath of archiveFiles) { let contents = await readFile(path.join(rootDirectory, relativePath)); if (relativePath === "manifest.json") { - const manifest = JSON.parse(contents.toString("utf8")); - manifest.version = version; + const manifest = createManifest(contents, version, target); contents = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); } @@ -142,7 +201,10 @@ async function packageExtension({ rootDirectory, outputDirectory, version: reque } const archive = createZip(entries); - const archiveName = `glagolify-${version}.xpi`; + const archiveName = + target === "firefox" + ? `glagolify-${version}.xpi` + : `glagolify-${version}-chromium.zip`; const archivePath = path.join(outputDirectory, archiveName); const checksumPath = `${archivePath}.sha256`; const digest = createHash("sha256").update(archive).digest("hex"); @@ -151,17 +213,22 @@ async function packageExtension({ rootDirectory, outputDirectory, version: reque await writeFile(archivePath, archive); await writeFile(checksumPath, `${digest} ${archiveName}\n`); - return { archiveName, archivePath, checksumPath, digest, version }; + return { archiveName, archivePath, checksumPath, digest, target, version }; } function parseArguments(arguments_) { let outputDirectory = "dist"; + let target = "all"; let version; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; - if (argument === "--version" || argument === "--output-dir") { + if ( + argument === "--version" || + argument === "--output-dir" || + argument === "--target" + ) { const value = arguments_[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`${argument} requires a value.`); @@ -169,8 +236,10 @@ function parseArguments(arguments_) { if (argument === "--version") { version = value; - } else { + } else if (argument === "--output-dir") { outputDirectory = value; + } else { + target = value; } index += 1; } else { @@ -178,22 +247,31 @@ function parseArguments(arguments_) { } } - return { outputDirectory, version }; + if (target !== "all" && !TARGETS.has(target)) { + throw new Error(`Unknown extension target: ${target}`); + } + + return { outputDirectory, target, version }; } async function main() { const rootDirectory = path.resolve(__dirname, ".."); const options = parseArguments(process.argv.slice(2)); - const result = await packageExtension({ - rootDirectory, - outputDirectory: path.resolve(rootDirectory, options.outputDirectory), - version: options.version - }); + const targets = options.target === "all" ? [...TARGETS] : [options.target]; - process.stdout.write( - `Created ${path.relative(rootDirectory, result.archivePath)} for version ${result.version}\n` + - `SHA-256 ${result.digest}\n` - ); + for (const target of targets) { + const result = await packageExtension({ + rootDirectory, + outputDirectory: path.resolve(rootDirectory, options.outputDirectory), + target, + version: options.version + }); + + process.stdout.write( + `Created ${path.relative(rootDirectory, result.archivePath)} for ${target} ` + + `version ${result.version}\nSHA-256 ${result.digest}\n` + ); + } } if (require.main === module) { @@ -203,4 +281,9 @@ if (require.main === module) { }); } -module.exports = { ARCHIVE_FILES, normalizeVersion, packageExtension }; +module.exports = { + CHROMIUM_ARCHIVE_FILES, + FIREFOX_ARCHIVE_FILES, + normalizeVersion, + packageExtension +}; diff --git a/tests/background.test.js b/tests/background.test.js index afccd14..4e8fc61 100644 --- a/tests/background.test.js +++ b/tests/background.test.js @@ -4,12 +4,14 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { createBackgroundController } = require("../background.js"); -function makeBrowser(initialStorage = {}) { +function makeBrowser(initialStorage = {}, initialSessionStorage = {}) { let messageListener; let removedListener; const broadcasts = []; const stored = { ...initialStorage }; + const sessionStored = { ...initialSessionStorage }; const storageWrites = []; + const sessionWrites = []; const api = { runtime: { onMessage: { @@ -27,6 +29,15 @@ function makeBrowser(initialStorage = {}) { storageWrites.push({ ...values }); Object.assign(stored, values); } + }, + session: { + async get(key) { + return { [key]: sessionStored[key] }; + }, + async set(values) { + sessionWrites.push(structuredClone(values)); + Object.assign(sessionStored, structuredClone(values)); + } } }, tabs: { @@ -45,13 +56,24 @@ function makeBrowser(initialStorage = {}) { return { api, broadcasts, + sessionStored, + sessionWrites, stored, storageWrites, send(message, sender = {}) { - return messageListener(message, sender); + return new Promise((resolve) => { + let responded = false; + const keepChannelOpen = messageListener(message, sender, (response) => { + responded = true; + resolve(response); + }); + if (!keepChannelOpen && !responded) { + resolve(undefined); + } + }); }, remove(tabId) { - removedListener(tabId); + return removedListener(tabId); } }; } @@ -91,10 +113,41 @@ test("owns absolute state per tab and supplies it to new frames", async () => { { enabled: false, fontStyle: "round" } ); - fake.remove(17); + await fake.remove(17); assert.equal(controller.tabStates.has(17), false); }); +test("restores tab state after an MV3 background worker restart", async () => { + const firstWorker = makeBrowser(); + createBackgroundController(firstWorker.api); + await firstWorker.send({ + type: "glagolify:set-tab-enabled", + tabId: 23, + enabled: true + }); + + assert.deepEqual(firstWorker.sessionStored, { + glagolifyTabStates: { 23: true } + }); + + const restartedWorker = makeBrowser({}, firstWorker.sessionStored); + createBackgroundController(restartedWorker.api); + assert.deepEqual( + await restartedWorker.send( + { type: "glagolify:document-ready" }, + { tab: { id: 23 } } + ), + { enabled: true, fontStyle: "round" } + ); + assert.deepEqual( + await restartedWorker.send({ + type: "glagolify:get-tab-state", + tabId: 23 + }), + { enabled: true, available: true, fontStyle: "round" } + ); +}); + test("keeps states isolated between tabs and reports protected tabs", async () => { const fake = makeBrowser(); createBackgroundController(fake.api); diff --git a/tests/package-extension.test.js b/tests/package-extension.test.js index 535e860..3d1798d 100644 --- a/tests/package-extension.test.js +++ b/tests/package-extension.test.js @@ -8,7 +8,8 @@ const os = require("node:os"); const path = require("node:path"); const { inflateRawSync } = require("node:zlib"); const { - ARCHIVE_FILES, + CHROMIUM_ARCHIVE_FILES, + FIREFOX_ARCHIVE_FILES, normalizeVersion, packageExtension } = require("../scripts/package-extension.js"); @@ -72,8 +73,12 @@ test("builds a deterministic unsigned XPI with the release version and runtime f const entries = readZipEntries(firstArchive); assert.equal(first.archiveName, "glagolify-9.8.7.xpi"); - assert.deepEqual([...entries.keys()], [...ARCHIVE_FILES]); - assert.equal(JSON.parse(entries.get("manifest.json")).version, "9.8.7"); + assert.deepEqual([...entries.keys()], [...FIREFOX_ARCHIVE_FILES]); + const manifest = JSON.parse(entries.get("manifest.json")); + assert.equal(manifest.version, "9.8.7"); + assert.equal(manifest.manifest_version, 2); + assert.equal(manifest.background.persistent, true); + assert.equal(manifest.browser_action.default_popup, "popup.html"); assert.equal(entries.has("package.json"), false); assert.equal(entries.has("tests/package-extension.test.js"), false); assert.deepEqual(firstArchive, secondArchive); @@ -84,3 +89,76 @@ test("builds a deterministic unsigned XPI with the release version and runtime f `${digest} glagolify-9.8.7.xpi\n` ); }); + +test("builds a Chromium MV3 ZIP with Chromium manifest fields and raster icons", async (context) => { + const rootDirectory = path.resolve(__dirname, ".."); + const outputDirectory = await mkdtemp(path.join(os.tmpdir(), "glagolify-chromium-")); + context.after(() => rm(outputDirectory, { recursive: true, force: true })); + + const result = await packageExtension({ + rootDirectory, + outputDirectory, + target: "chromium", + version: "v4.5.6" + }); + const archive = await readFile(result.archivePath); + const entries = readZipEntries(archive); + const manifest = JSON.parse(entries.get("manifest.json")); + + assert.equal(result.archiveName, "glagolify-4.5.6-chromium.zip"); + assert.deepEqual([...entries.keys()], [...CHROMIUM_ARCHIVE_FILES]); + assert.equal(manifest.manifest_version, 3); + assert.equal(manifest.minimum_chrome_version, "102"); + assert.equal(manifest.version, "4.5.6"); + assert.deepEqual(manifest.permissions, ["activeTab", "storage"]); + assert.deepEqual(manifest.host_permissions, [""]); + assert.deepEqual(manifest.background, { service_worker: "background.js" }); + assert.equal(manifest.action.default_popup, "popup.html"); + assert.equal("browser_action" in manifest, false); + assert.equal("browser_specific_settings" in manifest, false); + assert.deepEqual(manifest.web_accessible_resources, [ + { + resources: [ + "fonts/NotoSansGlagolitic-Regular.ttf", + "fonts/Shafarik-Regular.ttf" + ], + matches: [""] + } + ]); + assert.equal(manifest.icons["128"], "icons/icon-128.png"); + + for (const relativePath of CHROMIUM_ARCHIVE_FILES.filter((name) => + name.endsWith(".png") + )) { + assert.deepEqual( + entries.get(relativePath).subarray(0, 8), + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ); + } + const popup = entries.get("popup.html").toString("utf8"); + for (const [, relativePath] of popup.matchAll(/\b(?:href|src)="([^"]+)"/g)) { + assert.equal( + entries.has(relativePath), + true, + `${relativePath} referenced by popup.html should be packaged` + ); + } + + const digest = createHash("sha256").update(archive).digest("hex"); + assert.equal( + await readFile(result.checksumPath, "utf8"), + `${digest} glagolify-4.5.6-chromium.zip\n` + ); +}); + +test("rejects Chromium version components outside the MV3 manifest range", async () => { + await assert.rejects( + packageExtension({ + rootDirectory: path.resolve(__dirname, ".."), + outputDirectory: os.tmpdir(), + target: "chromium", + version: "65536" + }), + /cannot exceed 65535/ + ); +});