Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/DEVELOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Vitest + happy-dom, 850ms timeout. Chrome APIs mocked via `@Packages/chrome-exte
- BDD-style Chinese `describe`/`it` titles. Use `describe.concurrent()` / `it.concurrent()` where independent.
- Single file: `pnpm test -- --run path/to/file.test.ts`.
- Playwright tests are `*.spec.ts` files in `e2e`; they run with one worker and retain failure artifacts. Run targeted tests while iterating, then run `pnpm run lint` plus the relevant full suite before a PR.
- Firefox MV3 has a separate committed E2E suite (Selenium + geckodriver, `e2e/firefox/`) run via `pnpm run test:e2e:firefox` — Playwright cannot drive Firefox extension UI. See [`E2E-FIREFOX.md`](./E2E-FIREFOX.md).

### Writing meaningful tests (what to clean up / not write)

Expand Down
1 change: 1 addition & 0 deletions docs/DOC-MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Aspirational / feature-branch content belongs in that branch's docs, or is clear
| [`DEVELOP.md`](./DEVELOP.md) | The concrete "how": commands, structure, style, testing, i18n, commit/PR. |
| [`DESIGN.md`](./DESIGN.md) | The design system: light/dark color tokens, theme mechanism, shadcn component palette, layout & responsive patterns, motion, state patterns, new-page recipe. |
| [`VERIFICATION.md`](./VERIFICATION.md) | Lightweight end-to-end functional verification — throwaway scratch scripts driving the real built extension. |
| [`E2E-FIREFOX.md`](./E2E-FIREFOX.md) | The committed Firefox MV3 E2E suite (Selenium + geckodriver): how to run it, Firefox-specific hurdles, results. |
| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | Deep internals: process model, message passing, service/data layers, GM API, execution, build. |
| [`translation/README.md`](./translation/README.md) | Translation / localization single source of truth. |
| [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | This guide: doc-set organization rules + fact-check / anti-drift discipline. |
Expand Down
122 changes: 122 additions & 0 deletions docs/E2E-FIREFOX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Firefox MV3 E2E 指南 / Firefox MV3 E2E Guide

> **What this owns.** The **Firefox MV3** end-to-end suite: how ScriptCat is loaded into a real Firefox and
> driven end-to-end (install a userscript → run it → verify GM APIs), and the Firefox-specific hurdles that make
> it work. The Chrome/Chromium E2E suite (Playwright, `e2e/*.spec.ts`) and the general testing mechanics live in
> [`DEVELOP.md`](./DEVELOP.md) → *Testing*; lightweight throwaway verification lives in
> [`VERIFICATION.md`](./VERIFICATION.md). This is the committed, rerunnable Firefox suite.

> **Status:** ScriptCat MV3 does not *officially* support Firefox yet (`scripts/pack.js` keeps
> `PACK_FIREFOX = false`). This suite exists to drive the Firefox build end-to-end and guard the parts that
> already work.

## Why not Playwright

The Chrome E2E suite drives the extension with Playwright + `--load-extension`. **That does not work for
Firefox:** Playwright's Firefox cannot render *any* `moz-extension://` UI page — `page.goto`, a DNR redirect,
and the extension's own `tabs.create` all leave the tab at `about:blank` (Playwright issues
[#3792](https://github.com/microsoft/playwright/issues/3792) /
[#2644](https://github.com/microsoft/playwright/issues/2644)). Without the extension UI you cannot drive the
install page, so the Firefox suite uses **Selenium WebDriver + geckodriver** (Marionette), which renders
`moz-extension://` pages normally.

## Prerequisites

- **A build at `dist/ext`** — run `pnpm run build` (or `pnpm run dev`) first. The runner derives the Firefox
build from it.
- **A real Firefox ≥ 136** on the machine (the MV3 `userScripts` API landed in Firefox 136; the generated
manifest sets `strict_min_version: "136.0"`). geckodriver drives your system Firefox.
- Dev dependencies `selenium-webdriver` + `geckodriver` (already in `package.json`). The `geckodriver` npm
package downloads its binary on demand; no manual install.

## Running

```bash
pnpm run build # produce dist/ext (skip if already built)
pnpm run test:e2e:firefox # build dist/firefox, drive Firefox, run gm_api_sync, assert
```

Environment flags:

| Var | Effect |
| --- | --- |
| `HEADED=1` | Show the Firefox window instead of headless. |
| `NO_CSP=1` | Serve the target page **without** CSP; then a clean 29/29 is required (no failures allowed). |

Exit code `0` = pass. Screenshots are written to `test-results/ff-gm-sync-*.png` (install page, installed list,
result box), which is git-ignored.

## What it does

The runner ([`e2e/firefox/gm-api-sync.mjs`](../e2e/firefox/gm-api-sync.mjs)) is the Firefox counterpart of the
Chrome "GM_ sync API tests" in [`e2e/gm-api.spec.ts`](../e2e/gm-api.spec.ts):

1. **Build the Firefox add-on** ([`build-ext.mjs`](../e2e/firefox/build-ext.mjs)) — copy `dist/ext` → `dist/firefox`
and apply the same manifest transform as `scripts/pack.js` (drop `background.service_worker`, delete
`sandbox`, add `browser_specific_settings.gecko`).
2. **Launch Firefox** ([`driver.mjs`](../e2e/firefox/driver.mjs)) with ScriptCat installed as a temporary add-on.
3. **Start a mock server** ([`mock-server.mjs`](../e2e/firefox/mock-server.mjs)) that serves the target page
(with `script-src 'none'` CSP by default), the mocked GM endpoints (`/get`, `/favicon.ico`, `/bytes/N`, …),
and the patched userscript. The userscript is `example/tests/gm_api_sync_test.js`, patched the same way the
Chrome suite patches it (strip integrity hashes, `jsdelivr`→`unpkg`, rewrite `httpbun`/`@connect` to the
local mock).
4. **Install** the userscript through ScriptCat's **real install page**
(`moz-extension://<uuid>/src/install.html?url=…`) — click `[data-testid=install-primary]`.
5. **Run** — open the target page, auto-approve the runtime permission prompts (`confirm.html`), and read the
`通过`/`失败` summary the script renders.

## The one thing that makes it work: install the add-on *unpacked*

Selenium's `driver.installAddon(dir, true)` **zips** the directory before installing. From a zipped temporary
add-on, Firefox's content process cannot load content-script *source files* — you get
`IPDL protocol Error: Received an invalid file descriptor` → `Unable to load script: .../src/scripting.js`.
`src/scripting.js` is ScriptCat's ISOLATED content bridge (registered via `chrome.scripting.registerContentScripts`);
when it fails to load, the SW ↔ content ↔ inject GM chain never connects and **every userscript silently fails
to run** (no logs, no error).

The fix ([`driver.mjs`](../e2e/firefox/driver.mjs)): install **unpacked** by POSTing the directory path to
geckodriver's raw endpoint `POST /session/{id}/moz/addon/install` with `{ path: <dir>, temporary: true }`.
Inline-code `userScripts.register` (ScriptCat's inject/content bridges) is unaffected — only file-based
`scripting.registerContentScripts` breaks when zipped.

Other Firefox-specific setup handled by the driver:

- **Pin the moz-extension UUID** via the `extensions.webextensions.uuids` pref so install/options URLs are known
up front.
- **Pre-grant permissions** by writing `<profile>/extension-preferences.json` = `{ "<gecko-id>": { "permissions":
["userScripts"], "origins": ["<all_urls>"] } }` so ScriptCat can register userScripts and inject without an
interactive grant.
- **Match pattern** — the Chrome suite keeps `?gm_api_sync` in `@match`; Firefox match patterns do not match the
query string, so the userscript is patched to `@match http://127.0.0.1/*` (scoped by the per-run port).

## Results & the known CSP gap

On a normal (non-CSP) page the userscript passes **29/29** — every GM API works on Firefox. On the
`script-src 'none'` CSP page (the faithful, default target) it is **28/29**: the one failure is
`GM_addElement - 创建元素`, because `GM_addElement("script", { textContent })`'s injected inline script does
**not** bypass the page CSP on Firefox (it does on Chrome), so `unsafeWindow.foo === "bar"` fails.

The runner encodes this as a small allowlist (`KNOWN_CSP_GAPS`) so the suite stays green while still failing on
any **new** regression; if ScriptCat gains CSP bypass on Firefox the set just shrinks. Run with `NO_CSP=1` to
require a clean 29/29.

## Layout

```
e2e/firefox/
build-ext.mjs # dist/ext → dist/firefox (Firefox manifest transform)
driver.mjs # Selenium + geckodriver launch; unpacked temp-add-on install
mock-server.mjs # GM API mock server + userscript patchers (mirrors e2e/gm-api.spec.ts)
gm-api-sync.mjs # the test: install via UI, run gm_api_sync, assert, screenshot
```

The suite is **not** part of the Playwright run (`playwright.config.ts` ignores `**/firefox/**`); it is driven
only by `pnpm run test:e2e:firefox`.

## Troubleshooting

- **`Process (pid=…) unexpectedly closed with status 0`** — a transient Firefox launch flake; the driver retries
once. If it persists, kill stray `firefox` / `geckodriver` processes left by interrupted runs.
- **Script installs but never runs** (bare target page, no result box) — almost always the *zipped-add-on*
content-script failure above; confirm the add-on is installed unpacked.
- **`dist/ext not found`** — run `pnpm run build` first.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| [`DEVELOP.md`](./DEVELOP.md) | 开发规范:命令、目录结构、编码风格、UI/主题、测试机制、i18n、提交/PR 流程。**写代码前先读。** |
| [`DESIGN.md`](./DESIGN.md) | 设计系统参考:深/浅色令牌完整值、主题机制、shadcn 组件清单与选型、布局与响应式范式、动效、状态模式、新建页面配方。**做页面/对话框/区块前先读。** |
| [`VERIFICATION.md`](./VERIFICATION.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例)。**验证改动是否真正跑通时读。** |
| [`E2E-FIREFOX.md`](./E2E-FIREFOX.md) | Firefox MV3 E2E 套件(Selenium+geckodriver):怎么跑、解包安装等 Firefox 专属坑、gm_api_sync 结果与已知 CSP 差异。**动 Firefox e2e 时读。** |
| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | 内部原理深入:多进程模型、消息传递、服务/数据层、GM API、脚本执行、构建管线。 |
| [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** |

Expand Down
75 changes: 75 additions & 0 deletions e2e/firefox/build-ext.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* global process */
// Produce a Firefox-loadable unpacked extension at dist/firefox/ from the Chrome build at
// dist/ext/, applying the same manifest transform as scripts/pack.js: Firefox uses the
// event-page background (scripts), has no offscreen/sandbox pages, and needs
// browser_specific_settings.gecko.id for the temporary add-on install.
//
// Run standalone (`node e2e/firefox/build-ext.mjs`) or import buildFirefoxExt().
import { promises as fs } from "fs";
import path from "path";

const ROOT = path.resolve(import.meta.dirname, "../..");
const SRC = path.join(ROOT, "dist/ext");
const OUT = path.join(ROOT, "dist/firefox");

// Beta build (version 1.5.0-beta) → beta gecko id, per scripts/pack.js.
export const GECKO_ID = "{44ab8538-2642-46b0-8a57-3942dbc1a33b}";
// Pinned moz-extension UUID so options/install URLs are known up front. Firefox reads this
// from the extensions.webextensions.uuids pref set in firefoxUserPrefs at launch.
export const EXT_UUID = "44ab8538-2642-46b0-8a57-3942dbc1a33b";

async function copyDir(from, to) {
await fs.mkdir(to, { recursive: true });
for (const entry of await fs.readdir(from, { withFileTypes: true })) {
const s = path.join(from, entry.name);
const d = path.join(to, entry.name);
if (entry.isDirectory()) await copyDir(s, d);
else await fs.copyFile(s, d);
}
}

function toFirefoxManifest(manifest) {
const m = { ...manifest, background: { ...manifest.background } };
// Firefox MV3 uses the event-page background; drop the Chrome service worker.
delete m.background.service_worker;
// Firefox has no chrome.sandbox page mechanism.
delete m.sandbox;
// "background" optional permission is unsupported on Firefox MV3.
m.optional_permissions = (m.optional_permissions || []).filter((p) => p !== "background");
m.permissions = (m.permissions || []).filter((p) => p !== "background");
m.browser_specific_settings = {
gecko: {
id: GECKO_ID,
strict_min_version: "136.0",
data_collection_permissions: {
required: ["none"],
optional: ["authenticationInfo", "personallyIdentifyingInfo"],
},
},
};
m.commands = { _execute_action: {} };
return m;
}

export async function buildFirefoxExt() {
try {
await fs.access(path.join(SRC, "manifest.json"));
} catch {
throw new Error(`dist/ext not found — run \`pnpm run build\` (or \`pnpm run dev\`) first, then re-run.`);
}
await fs.rm(OUT, { recursive: true, force: true });
await copyDir(SRC, OUT);
const manifest = JSON.parse(await fs.readFile(path.join(SRC, "manifest.json"), "utf8"));
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(toFirefoxManifest(manifest), null, 2));
return OUT;
}

if (import.meta.url === `file://${process.argv[1]}`) {
buildFirefoxExt().then(
(out) => console.log("[build-ext] wrote", out, "gecko id", GECKO_ID),
(err) => {
console.error(err.message);
process.exit(1);
}
);
}
124 changes: 124 additions & 0 deletions e2e/firefox/driver.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/* global Buffer */
// Launch Firefox via Selenium + geckodriver with ScriptCat installed as a temporary MV3
// add-on. Unlike Playwright's Firefox, geckodriver (Marionette) can render moz-extension://
// UI pages, so this drives ScriptCat's real install page / options UI.
//
// The add-on is installed UNPACKED (directory) through geckodriver's raw moz/addon/install
// endpoint rather than driver.installAddon(), which zips the directory. From a zipped temp
// add-on Firefox's content process cannot load content-script *source files* ("IPDL protocol
// Error: invalid file descriptor" → "Unable to load script: .../src/scripting.js"), which
// breaks ScriptCat's content bridge and silently stops every userscript from running.
import fs from "fs";
import os from "os";
import path from "path";
import net from "net";
import http from "http";
import { Builder } from "selenium-webdriver";
import firefox from "selenium-webdriver/firefox.js";
import { createRequire } from "module";
import { GECKO_ID, EXT_UUID } from "./build-ext.mjs";

const require = createRequire(import.meta.url);
const { download, start } = require("geckodriver");

const EXT_DIR = path.resolve(import.meta.dirname, "../../dist/firefox");

function freeTcpPort() {
return new Promise((resolve) => {
const s = net.createServer();
s.listen(0, "127.0.0.1", () => {
const p = s.address().port;
s.close(() => resolve(p));
});
});
}

function installUnpackedAddon(port, sessionId, dir) {
return new Promise((resolve, reject) => {
const data = JSON.stringify({ path: dir, temporary: true });
const req = http.request(
{
host: "127.0.0.1",
port,
path: `/session/${sessionId}/moz/addon/install`,
method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
},
(res) => {
let b = "";
res.on("data", (c) => (b += c));
res.on("end", () =>
res.statusCode === 200 ? resolve(JSON.parse(b).value) : reject(new Error(`install ${res.statusCode}: ${b}`))
);
}
);
req.on("error", reject);
req.write(data);
req.end();
});
}

export async function launchFirefox({ headless = true } = {}) {
// Profile template pre-granting the MV3 host controls + userScripts optional permission,
// so ScriptCat can register userScripts and inject without an interactive grant.
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "ff-sc-prof-"));
fs.writeFileSync(
path.join(profileDir, "extension-preferences.json"),
JSON.stringify({ [GECKO_ID]: { permissions: ["userScripts"], origins: ["<all_urls>"] } })
);

const options = new firefox.Options();
if (headless) options.addArguments("-headless");
options.setPreference("extensions.manifestV3.enabled", true);
// Pin the internal moz-extension UUID so options/install URLs are known up front.
options.setPreference("extensions.webextensions.uuids", JSON.stringify({ [GECKO_ID]: EXT_UUID }));
options.setPreference("xpinstall.signatures.required", false);
// Extension pages open new tabs; keep them in the same window so Selenium can switch.
options.setPreference("browser.link.open_newwindow", 3);
if (typeof options.setProfile === "function") options.setProfile(profileDir);

const geckoPath = await download();
// Retry once: launching a real Firefox occasionally flakes ("Process ... unexpectedly
// closed with status 0") on the first try, especially under load.
let lastErr;
for (let attempt = 1; attempt <= 2; attempt++) {
const port = await freeTcpPort();
const gecko = await start({
customGeckoDriverPath: geckoPath,
host: "127.0.0.1",
port,
spawnOpts: { stdio: ["ignore", "ignore", "ignore"] },
});
await new Promise((r) => setTimeout(r, 1200));
try {
const driver = await new Builder()
.usingServer(`http://127.0.0.1:${port}`)
.forBrowser("firefox")
.setFirefoxOptions(options)
.build();
const sessionId = (await driver.getSession()).getId();
const addonId = await installUnpackedAddon(port, sessionId, EXT_DIR);
const extUrl = (p) => `moz-extension://${EXT_UUID}/${p.replace(/^\//, "")}`;
const cleanup = async () => {
await driver.quit().catch(() => {});
try {
gecko.kill();
} catch {
/* already exited */
}
fs.rmSync(profileDir, { recursive: true, force: true });
};
return { driver, addonId, extUrl, cleanup };
} catch (e) {
lastErr = e;
try {
gecko.kill();
} catch {
/* already exited */
}
await new Promise((r) => setTimeout(r, 1000));
}
}
fs.rmSync(profileDir, { recursive: true, force: true });
throw lastErr;
}
Loading
Loading