Skip to content

Commit 1205b87

Browse files
CodFrmclaude
andcommitted
🐛 e2e 模块不再在顶层加载 @playwright/test,修打包 CI 随机失败
v1.5.0-beta.2 的 Auto_Package 挂在 `pnpm test`:tests/verification-tools.test.ts 收集失败,报 "Requiring @playwright/test second time",4190 个断言一个没挂, release-action 被跳过、tag 没发出产物。 根因:@playwright/test 的单例守卫(playwright/lib/index.js:61)挂在 process 上, 而 e2e/fixtures.ts、e2e/session.mjs、e2e/drive.mjs 都在模块顶层加载它。tests/ 下 两个单测分别引用了 fixtures.ts 和 session.mjs,于是 vitest 进程里存在两条独立的 加载路径 —— 一旦它们被调度到同一个 worker 线程(fast 项目是 vmThreads + isolate:false),第二次求值就抛错。 之所以一直没被发现:PR/push 走 test.yaml 的 `--shard=i/2`,这两个文件恰好被切进 不同分片、天然隔离;tag 构建的 `pnpm test` 不分片,是它们首次同进程。本地机器核多 (8 核 → 6 worker),插桩打 threadId 观测到二者始终落在不同线程,所以本地全量也是绿的。 改动: - 抽出 e2e/launch-args.ts 承载 headlessArgs(纯逻辑、零驱动依赖),fixtures.ts / server-fixtures.ts / agent-fixtures.ts / gm-api.spec.ts 与单测改引此处; fixtures.ts 的 base.extend 在模块作用域求值,无法延迟,只能抽离 - session.mjs / drive.mjs 各自只有一个调用点,require 挪到调用处;drive.mjs 的 console 命令因此也不再加载驱动 - 新增子进程探针测试守住 .mjs 侧,新增 tests/** 的 no-restricted-imports 守住 .ts 侧 (该块置于 vitest.setup.ts 专属块之前,否则会顶掉后者更具体的重型模块禁令) 验证:新测试改前红改后绿(stash 回退两个 .mjs 复验);全量套件 ×3 全绿 340/340; shuffle 压测 ×10 零复现(改前 1/5~1/8 必中);session start → drive open → console → stop 全流程实跑通过;options / gm-xhr / agent-chat / gm-api 各抽 spec 实跑通过; pnpm lint 全过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 87638a5 commit 1205b87

11 files changed

Lines changed: 78 additions & 19 deletions

e2e/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ None are required; each one only switches on when set. `.env` is **not** loaded
123123
| `E2E_PROXY` | [`fixtures.ts`](./fixtures.ts), [`agent-fixtures.ts`](./agent-fixtures.ts) | Chromium proxy for the launched context. Falls back to `https_proxy` / `http_proxy` / `HTTPS_PROXY` / `HTTP_PROXY`. Needed for the non-hermetic specs above on a restricted network. |
124124
| `E2E_RECORD_VIDEO_DIR` | [`fixtures.ts`](./fixtures.ts) **only** | Records video into that directory. Off by default. Point it at your scenario directory, e.g. `e2e/scratch/<scenario>/videos`. `server-fixtures.ts` / `agent-fixtures.ts`, and any spec that copies a fixture inline instead of importing it, do **not** honour this. |
125125
| `E2E_ONEDRIVE_TOKEN_FILE` | local scratch scripts only — **not referenced by any committed file** | Path to a OneDrive token JSON for real-provider cloud-sync verification, conventionally defaulting to `~/.config/scriptcat/e2e-onedrive-token.json`. Real account, real side effects — only with authorization. Recorded here because nothing in-tree can tell you it exists. |
126-
| `E2E_HEADED` | `headlessArgs()` in [`fixtures.ts`](./fixtures.ts), imported by every other fixture | Set to `1`, `true`, or `yes` to launch a **visible** window instead of the default `--headless=new`; unset it (or use `0`/`false`) otherwise. `session.mjs --headed` does the same for a session. |
126+
| `E2E_HEADED` | `headlessArgs()` in [`launch-args.ts`](./launch-args.ts), imported by every fixture | Set to `1`, `true`, or `yes` to launch a **visible** window instead of the default `--headless=new`; unset it (or use `0`/`false`) otherwise. `session.mjs --headed` does the same for a session. |
127127
| `CI` | every fixture, plus [`playwright.config.ts`](../playwright.config.ts) | Disables the Chromium sandbox, and switches Playwright to 1 retry / 2 workers / HTML reporter / `forbidOnly`. Set by GitHub Actions; don't set it by hand. |
128128

129129
Secrets never belong in a committed spec or in `report.md` — see the redaction rules in

e2e/agent-fixtures.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "fs";
22
import os from "os";
33
import path from "path";
44
import { test as base, expect, chromium, type BrowserContext, type Route } from "@playwright/test";
5-
import { headlessArgs } from "./fixtures";
5+
import { headlessArgs } from "./launch-args";
66
export { expect };
77

88
const pathToExtension = path.resolve(__dirname, "../dist/ext");

e2e/drive.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { createRequire } from "node:module";
1515
import { liveSessions, readSession, isAlive, scenarioDir } from "./session.mjs";
1616

1717
const require = createRequire(import.meta.url);
18-
const { chromium } = require("@playwright/test");
1918

2019
const __filename = fileURLToPath(import.meta.url);
2120
const REPO_ROOT = path.resolve(path.dirname(__filename), "..");
@@ -164,6 +163,8 @@ async function run() {
164163
return console.log(lines.slice(-count).join("\n"));
165164
}
166165

166+
// 同 session.mjs:驱动延后到真正要附着浏览器时才加载,上面的 console 命令因此也不必付这份代价。
167+
const { chromium } = require("@playwright/test");
167168
const browser = await chromium.connectOverCDP(session.cdp);
168169
const context = browser.contexts()[0];
169170

e2e/fixtures.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "fs";
22
import os from "os";
33
import path from "path";
44
import { test as base, chromium, type BrowserContext } from "@playwright/test";
5+
import { headlessArgs } from "./launch-args";
56

67
const pathToExtension = path.resolve(__dirname, "../dist/ext");
78

@@ -25,18 +26,6 @@ const chromeArgs = [
2526
"--disable-gpu",
2627
];
2728

28-
/**
29-
* 无头是默认:跑用例不该抢占桌面焦点,也才能让多个 worktree 同时跑。
30-
* `E2E_HEADED=1` 开出可见窗口,只为人工旁观用。
31-
*
32-
* 必须作为启动参数下发:`--headless=new` 会覆盖 Playwright 的 `headless` 选项,
33-
* 所以单独把 `headless` 设成 false(含 `--debug`/PWDEBUG)并不会开出窗口。
34-
*/
35-
export function headlessArgs(value = process.env.E2E_HEADED): string[] {
36-
const headed = /^(1|true|yes)$/i.test(value?.trim() ?? "");
37-
return headed ? [] : ["--headless=new"];
38-
}
39-
4029
// CI(GitHub Actions)跑在非 root 用户下不会自动应用 --no-sandbox,关掉沙箱能省下每次
4130
// launchPersistentContext 的 sandbox/fork 开销;本地开发机上仍保留沙箱隔离。
4231
const chromiumSandbox = !process.env.CI;

e2e/gm-api.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import os from "os";
44
import { createServer, STATUS_CODES, type IncomingMessage, type ServerResponse } from "http";
55
import type { AddressInfo } from "net";
66
import { test as base, expect, chromium, type BrowserContext, type Page } from "@playwright/test";
7-
import { headlessArgs } from "./fixtures";
7+
import { headlessArgs } from "./launch-args";
88
import { autoApprovePermissions, installScriptByCode } from "./utils";
99

1010
const MOCK_CONNECT_HOST = "127.0.0.1";

e2e/launch-args.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Chrome 启动参数 —— 不依赖 @playwright/test。
3+
*
4+
* 单独成文件是因为 @playwright/test 是进程级单例(被求值两次直接抛错),
5+
* 而 vitest 单测要能引用这里的纯逻辑;从 fixtures 引就会把浏览器驱动一并拖进单测进程。
6+
*/
7+
8+
/**
9+
* 无头是默认:跑用例不该抢占桌面焦点,也才能让多个 worktree 同时跑。
10+
* `E2E_HEADED=1` 开出可见窗口,只为人工旁观用。
11+
*
12+
* 必须作为启动参数下发:`--headless=new` 会覆盖 Playwright 的 `headless` 选项,
13+
* 所以单独把 `headless` 设成 false(含 `--debug`/PWDEBUG)并不会开出窗口。
14+
*/
15+
export function headlessArgs(value = process.env.E2E_HEADED): string[] {
16+
const headed = /^(1|true|yes)$/i.test(value?.trim() ?? "");
17+
return headed ? [] : ["--headless=new"];
18+
}

e2e/server-fixtures.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "path";
44
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "http";
55
import type { AddressInfo } from "net";
66
import { test as base, expect, chromium, type BrowserContext } from "@playwright/test";
7-
import { headlessArgs } from "./fixtures";
7+
import { headlessArgs } from "./launch-args";
88

99
/**
1010
* 共享网络测试 fixture。

e2e/session.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import { fileURLToPath } from "node:url";
2323
import { createRequire } from "node:module";
2424

2525
const require = createRequire(import.meta.url);
26-
const { chromium } = require("@playwright/test");
2726

2827
const __filename = fileURLToPath(import.meta.url);
2928
const E2E_DIR = path.dirname(__filename);
@@ -280,6 +279,10 @@ export async function attachConsoleCollector(port, append, onDisconnect = () =>
280279
* 常驻进程本体:持有浏览器直到收到 SIGTERM。
281280
*/
282281
async function serve(scenario, { headed, lockToken }) {
282+
// 浏览器驱动只在真正要开浏览器时才加载:@playwright/test 是进程级单例,被求值两次会直接抛错,
283+
// 而本模块的纯逻辑(scenarioDir、attachConsoleCollector)要能被 vitest 单测同进程引用。
284+
const { chromium } = require("@playwright/test");
285+
283286
requireBuiltExtension();
284287
claimLock(scenario, lockToken);
285288

eslint.config.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,32 @@ export default [
9797
files: ["src/pages/components/ui/toast.ts"],
9898
rules: { "no-restricted-imports": "off" },
9999
},
100+
{
101+
// 单测和 @playwright/test 跑在同一个进程里,而它有进程级单例守卫(被求值两次直接抛错)。
102+
// e2e fixture 在模块顶层加载驱动,被单测引入后整个 vitest 套件会随 worker 线程调度随机崩
103+
// (2026-08-21 v1.5.0-beta.2 打包即因此失败)。纯逻辑请放到不依赖驱动的模块再引,
104+
// 例如 e2e/launch-args.ts。
105+
files: ["tests/**"],
106+
rules: {
107+
"no-restricted-imports": [
108+
"error",
109+
{
110+
paths: [
111+
{
112+
name: "@playwright/test",
113+
message: "单测不能加载浏览器驱动,它是进程级单例;需要浏览器请写 e2e spec。",
114+
},
115+
],
116+
patterns: [
117+
{
118+
group: ["**/e2e/fixtures", "**/e2e/*-fixtures", "**/e2e/utils"],
119+
message: "这些 e2e 模块在顶层加载 @playwright/test;把要测的纯逻辑抽到不依赖驱动的模块再引。",
120+
},
121+
],
122+
},
123+
],
124+
},
125+
},
100126
{
101127
// 全局测试 setup 每个测试文件都要加载,引入重型模块会拖慢整个套件
102128
// (见 docs/references/develop-testing.md § Vitest Performance Hygiene)。

tests/verification-tools.test.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,28 @@ afterEach(() => {
3232
});
3333

3434
describe("verification session paths", () => {
35+
// @playwright/test 有进程级单例守卫(第二次求值直接抛错),而单测和它同进程:
36+
// 一旦 session/drive 在模块顶层加载浏览器驱动,整个 vitest 套件就会随 worker 线程调度随机崩。
37+
it("importing the session tools does not load the browser driver", async () => {
38+
const probe = ['await import("./e2e/drive.mjs");', 'if (process["__pw_initiator__"]) process.exit(1);'].join("\n");
39+
// 用异步 spawn 而不是 spawnSync:fast 项目 isolate:false,同 worker 线程上还有别的用例在跑,
40+
// 同步阻塞会把它们挤出 340ms 预算。
41+
const child = spawn(process.execPath, ["--input-type=module", "-e", probe], {
42+
cwd: process.cwd(),
43+
stdio: ["ignore", "ignore", "pipe"],
44+
});
45+
let stderr = "";
46+
child.stderr.setEncoding("utf8");
47+
child.stderr.on("data", (chunk) => (stderr += chunk));
48+
const code = await new Promise((resolve, reject) => {
49+
child.once("error", reject);
50+
child.once("close", resolve);
51+
});
52+
53+
expect(stderr).toBe("");
54+
expect(code).toBe(0);
55+
}, 10_000);
56+
3557
it("rejects scenario names that escape the scratch directory", () => {
3658
expect(() => scenarioDir("../outside")).toThrow(/scenario/i);
3759
});

0 commit comments

Comments
 (0)