dsh-browser-playwright-codex 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +486 -0
  2. package/LICENSE +21 -0
  3. package/README.md +322 -0
  4. package/SECURITY.md +114 -0
  5. package/cordis.patch.yml +66 -0
  6. package/lib/cdp-policy.d.ts +69 -0
  7. package/lib/cdp-policy.js +105 -0
  8. package/lib/compat.d.ts +37 -0
  9. package/lib/compat.js +31 -0
  10. package/lib/contract.d.ts +74 -0
  11. package/lib/contract.js +117 -0
  12. package/lib/errors.d.ts +25 -0
  13. package/lib/errors.js +21 -0
  14. package/lib/index.d.ts +20 -0
  15. package/lib/index.js +12 -0
  16. package/lib/injected.d.ts +35 -0
  17. package/lib/injected.js +350 -0
  18. package/lib/playwright.d.ts +647 -0
  19. package/lib/playwright.js +2515 -0
  20. package/lib/runtime-state.d.ts +45 -0
  21. package/lib/runtime-state.js +181 -0
  22. package/lib/service.d.ts +56 -0
  23. package/lib/service.js +96 -0
  24. package/lib/snapshot-aria.d.ts +121 -0
  25. package/lib/snapshot-aria.js +485 -0
  26. package/lib/snapshot-render.d.ts +28 -0
  27. package/lib/snapshot-render.js +100 -0
  28. package/lib/tool.d.ts +77 -0
  29. package/lib/tool.js +907 -0
  30. package/lib/types.d.ts +242 -0
  31. package/lib/types.js +5 -0
  32. package/locale/en.json +6 -0
  33. package/locale/zh.json +6 -0
  34. package/package.json +108 -0
  35. package/scripts/analysis/hl-compare.mjs +149 -0
  36. package/scripts/analysis/live-handoff-probe.mjs +97 -0
  37. package/scripts/analysis/needs-human-probe.mjs +83 -0
  38. package/scripts/analysis/repro-minimize.mjs +112 -0
  39. package/scripts/autocommit.ps1 +23 -0
  40. package/scripts/check-doc-links.py +112 -0
  41. package/scripts/doctor.mjs +144 -0
  42. package/scripts/dsh-browser-verify.mjs +475 -0
  43. package/scripts/measure-prompt-cost.mjs +57 -0
  44. package/scripts/prompt-cost.mjs +73 -0
  45. package/scripts/shipped-gates.mjs +43 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Headless-mode capability probe, aimed at the three "needs a human" cases:
3
+ *
4
+ * 1. Does the window-state CDP call used by the minimize gate even work headless?
5
+ * (If it throws, our gate returns "minimized" and silently disables the
6
+ * login-state export in headless — i.e. session cookies would never be saved.)
7
+ * 2. Do page-level native dialogs (alert/confirm/prompt) park and answer headless?
8
+ * 3. Does a file input accept setInputFiles headless, i.e. no OS picker needed?
9
+ */
10
+ import { createRequire } from 'node:module'
11
+ import path from 'node:path'
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+
15
+ const require = createRequire(import.meta.url)
16
+ const { chromium } = require(process.env.PW_CORE_PATH ?? 'playwright-core')
17
+
18
+ const headless = (process.env.MODE ?? 'headless') === 'headless'
19
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-needsprobe-'))
20
+ const context = await chromium.launchPersistentContext(profile, {
21
+ headless,
22
+ channel: process.env.CHANNEL || undefined,
23
+ ...(headless ? { viewport: { width: 1280, height: 800 } } : { viewport: null }),
24
+ ignoreDefaultArgs: ['--enable-automation'],
25
+ args: ['--no-first-run', '--no-default-browser-check', '--disable-blink-features=AutomationControlled'],
26
+ })
27
+ const page = context.pages()[0] ?? (await context.newPage())
28
+ const out = { mode: headless ? 'headless' : 'headful' }
29
+
30
+ // --- 1. the window-state probe the plugin's gate relies on -------------------
31
+ try {
32
+ const s = await context.newCDPSession(page)
33
+ const { windowId } = await s.send('Browser.getWindowForTarget')
34
+ const { bounds } = await s.send('Browser.getWindowBounds', { windowId })
35
+ out.windowStateProbe = { ok: true, windowId, windowState: bounds.windowState, bounds }
36
+ out.gateWouldReturn = bounds.windowState === 'minimized'
37
+ await s.detach()
38
+ } catch (e) {
39
+ out.windowStateProbe = { ok: false, error: String(e?.message ?? e).split('\n')[0] }
40
+ // The plugin's catch returns true == "treat as minimized".
41
+ out.gateWouldReturn = true
42
+ }
43
+
44
+ // --- 2. native dialog without a window --------------------------------------
45
+ const dialogLog = []
46
+ page.on('dialog', async (d) => {
47
+ dialogLog.push({ type: d.type(), message: d.message() })
48
+ await d.accept('typed-by-test').catch(async () => {
49
+ await d.dismiss().catch(() => {})
50
+ })
51
+ })
52
+
53
+ async function dialogRoundTrip() {
54
+ await page.setContent('<html><body><button id=b>go</button></body></html>')
55
+ const result = await page.evaluate(() => {
56
+ const answer = prompt('probe-prompt', 'default')
57
+ return 'prompt-returned:' + String(answer)
58
+ })
59
+ return result
60
+ }
61
+ try {
62
+ out.dialog = { evaluateResult: await dialogRoundTrip(), events: dialogLog }
63
+ } catch (e) {
64
+ out.dialog = { error: String(e?.message ?? e).split('\n')[0], events: dialogLog }
65
+ }
66
+
67
+ // --- 3. file input without an OS picker ------------------------------------
68
+ try {
69
+ const tmpFile = path.join(os.tmpdir(), 'pw-upload-probe.txt')
70
+ fs.writeFileSync(tmpFile, 'upload-probe')
71
+ await page.setContent('<html><body><input id=f type=file></body></html>')
72
+ await page.setInputFiles('#f', tmpFile)
73
+ out.fileInput = {
74
+ ok: true,
75
+ files: await page.evaluate(() => Array.from(document.querySelector('#f').files).map((f) => f.name)),
76
+ }
77
+ } catch (e) {
78
+ out.fileInput = { ok: false, error: String(e?.message ?? e).split('\n')[0] }
79
+ }
80
+
81
+ console.log(JSON.stringify(out, null, 2))
82
+ await context.close()
83
+ fs.rmSync(profile, { recursive: true, force: true })
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Minimal, plugin-free reproduction of "automation raises a minimized window".
3
+ *
4
+ * Two modes, matching the two ways dsh-browser-playwright can own a window:
5
+ * PW_MODE=launch -> chromium.launch() + browser.newContext() (upstream 0.1.x default shape)
6
+ * PW_MODE=persistent -> chromium.launchPersistentContext() (persistent-profile forks)
7
+ *
8
+ * The OS window state is read back through CDP (Browser.getWindowBounds) and the
9
+ * window is minimized through CDP too, so the verdict does not depend on anyone
10
+ * watching the screen.
11
+ *
12
+ * Usage (Windows, using an installed Chrome):
13
+ * set PW_MODE=launch
14
+ * set PW_CHANNEL=chrome
15
+ * set PW_CORE_PATH=D:\path\to\node_modules\playwright-core
16
+ * node repro-minimize.mjs
17
+ */
18
+ import { createRequire } from 'node:module'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import fs from 'node:fs'
22
+
23
+ const require = createRequire(import.meta.url)
24
+ const corePath = process.env.PW_CORE_PATH ?? 'playwright-core'
25
+ const { chromium } = require(corePath)
26
+ let coreVersion = 'unknown'
27
+ try {
28
+ coreVersion = require(path.join(corePath, 'package.json')).version
29
+ } catch {}
30
+
31
+ const MODE = process.env.PW_MODE ?? 'launch'
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
33
+ const launchOptions = {
34
+ headless: false,
35
+ channel: process.env.PW_CHANNEL || undefined, // e.g. 'chrome' to use the local Chrome
36
+ args: ['--no-first-run', '--no-default-browser-check'],
37
+ }
38
+
39
+ let browser
40
+ let context
41
+ let profile
42
+ if (MODE === 'persistent') {
43
+ profile = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-focus-repro-'))
44
+ context = await chromium.launchPersistentContext(profile, { ...launchOptions, viewport: null })
45
+ } else {
46
+ browser = await chromium.launch(launchOptions)
47
+ context = await browser.newContext({ viewport: null })
48
+ }
49
+
50
+ const anchor = context.pages()[0] ?? (await context.newPage())
51
+ // Real content matters: an empty about:blank window can behave differently.
52
+ await anchor.goto('https://example.com', { waitUntil: 'domcontentloaded' }).catch(() => {})
53
+ await sleep(500)
54
+
55
+ const cdp = await context.newCDPSession(anchor)
56
+ const windowId = async () => (await cdp.send('Browser.getWindowForTarget')).windowId
57
+ const state = async () => (await cdp.send('Browser.getWindowBounds', { windowId: await windowId() })).bounds.windowState
58
+ const setState = async (windowState) => {
59
+ const id = await windowId()
60
+ return cdp.send('Browser.setWindowBounds', { windowId: id, bounds: { windowState } })
61
+ }
62
+
63
+ const results = []
64
+ async function probe(name, fn) {
65
+ await setState('normal')
66
+ await sleep(400)
67
+ await setState('minimized')
68
+ await sleep(900)
69
+ const before = await state()
70
+ let err = ''
71
+ try {
72
+ await fn()
73
+ } catch (e) {
74
+ err = ' [' + (e?.message ?? e) + ']'
75
+ }
76
+ await sleep(1400)
77
+ const after = await state()
78
+ const raised = before === 'minimized' && after !== 'minimized'
79
+ results.push({ name, before, after, raised })
80
+ console.log(`${raised ? 'RAISES ' : 'safe '} ${name.padEnd(46)} before=${before} after=${after}${err}`)
81
+ }
82
+
83
+ console.log(`mode=${MODE} playwright-core ${coreVersion} node ${process.version}`)
84
+ console.log(`channel=${process.env.PW_CHANNEL || '(bundled chromium)'}\n`)
85
+
86
+ await probe('baseline: sleep only (3s)', () => sleep(3000))
87
+ await probe('context.newPage()', async () => {
88
+ const p = await context.newPage()
89
+ await p.goto('about:blank')
90
+ await p.close()
91
+ })
92
+ await probe('CDP Target.createTarget({background:true})', async () => {
93
+ await cdp.send('Target.createTarget', { url: 'about:blank', background: true })
94
+ })
95
+ await probe('page.bringToFront()', () => anchor.bringToFront())
96
+ await probe('context.newCDPSession(anchor)', async () => {
97
+ const s = await context.newCDPSession(anchor)
98
+ await s.detach()
99
+ })
100
+ await probe('context.storageState()', () => context.storageState())
101
+ await probe('anchor.goto() (same tab)', () =>
102
+ anchor.goto('https://example.com/?x=1', { waitUntil: 'domcontentloaded' }),
103
+ )
104
+ await probe('anchor.screenshot()', () => anchor.screenshot({ path: path.join(os.tmpdir(), 'pw-repro.png') }))
105
+
106
+ console.log('\n--- summary ---')
107
+ for (const r of results) console.log(`${r.raised ? 'RAISES' : 'safe '} ${r.name}`)
108
+
109
+ await setState('normal').catch(() => {})
110
+ await context.close().catch(() => {})
111
+ await browser?.close().catch(() => {})
112
+ if (profile) fs.rmSync(profile, { recursive: true, force: true })
@@ -0,0 +1,23 @@
1
+ # autocommit.ps1 —— 每日自动存档:有改动就提交一条带时间戳的 commit。
2
+ #
3
+ # 用法:
4
+ # powershell -ExecutionPolicy Bypass -File D:\dsh-plugins\dsh-browser-playwright-codex\scripts\autocommit.ps1
5
+ #
6
+ # 注册成每天 23:55 的计划任务(一次即可,用户级,不需要管理员):
7
+ # schtasks /create /tn "dsh-browser-playwright autocommit" /sc daily /st 23:55 ^
8
+ # /tr "powershell -ExecutionPolicy Bypass -File D:\dsh-plugins\dsh-browser-playwright-codex\scripts\autocommit.ps1"
9
+ #
10
+ # 说明:这是"存档",不是"分支管理"。它只提交,从不 push、reset 或丢弃改动,
11
+ # 所以最坏情况也只是历史里多一条记录。
12
+ $ErrorActionPreference = 'Stop'
13
+ $repo = Split-Path -Parent $PSScriptRoot
14
+ Set-Location $repo
15
+
16
+ if (-not (Test-Path (Join-Path $repo '.git'))) { Write-Output "not a git repo: $repo"; exit 0 }
17
+
18
+ $changes = git status --porcelain
19
+ if (-not $changes) { Write-Output '没有改动,跳过'; exit 0 }
20
+
21
+ git add -A
22
+ git commit -q -m ("auto: " + (Get-Date -Format 'yyyy-MM-dd HH:mm'))
23
+ Write-Output ("已存档: " + (Get-Date -Format 'yyyy-MM-dd HH:mm'))
@@ -0,0 +1,112 @@
1
+ """校验仓库里所有 markdown 链接:文件路径要存在,锚点要能在目标文档里找到。
2
+
3
+ 为什么需要:
4
+ 1. 那几篇文档原来放在 GitHub 的 docs/ 下,链接写的是 ../xxx;搬进本仓库根目录后
5
+ ../ 就指到仓库外了。靠肉眼容易漏,脚本一跑就知道。
6
+ 2. 锚点也会坏:曾经有过 `[help wanted #2](README.md#help-wanted--求助)`,
7
+ 而 README 里根本没有那一节 —— 文件在、锚点不在,照样是死链。
8
+
9
+ 跑法(无需依赖,Python 3.8+):
10
+ python scripts/check-doc-links.py
11
+ 退出码 0 = 全部有效;1 = 有坏链(逐条列出,标明是文件缺失还是锚点缺失)。
12
+ """
13
+ import os
14
+ import re
15
+ import sys
16
+
17
+ # 以本文件位置为准:scripts/ 的上一级就是仓库根,换机器也不用改路径
18
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
+ LINK = re.compile(r"\]\(([^)\s]+)\)")
20
+ HEADING = re.compile(r"^#{1,6}\s+(.*?)\s*$")
21
+ FENCE = re.compile(r"^\s*(```|~~~)")
22
+
23
+
24
+ def slug(heading: str) -> str:
25
+ """GitHub 的标题锚点规则(近似实现):小写;去掉非字母数字/连字符/下划线;
26
+ 空格转连字符。中文是字母数字(str.isalnum() 为真)所以保留,与 GitHub 一致。"""
27
+ out = []
28
+ for ch in heading.strip().lower():
29
+ if ch.isalnum() or ch in "-_":
30
+ out.append(ch)
31
+ elif ch == " ":
32
+ out.append("-")
33
+ return "".join(out)
34
+
35
+
36
+ def anchors_of(path: str) -> set:
37
+ """该文档所有标题的锚点。代码块内的 # 行不算标题。"""
38
+ found = set()
39
+ in_fence = False
40
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
41
+ for line in f:
42
+ if FENCE.match(line):
43
+ in_fence = not in_fence
44
+ continue
45
+ if in_fence:
46
+ continue
47
+ m = HEADING.match(line)
48
+ if m:
49
+ found.add(slug(m.group(1)))
50
+ return found
51
+
52
+
53
+ def main() -> int:
54
+ bad = []
55
+ checked_files = 0
56
+ checked_anchors = 0
57
+ anchor_cache = {}
58
+
59
+ for root, dirs, files in os.walk(REPO):
60
+ dirs[:] = [d for d in dirs if d not in (".git", "node_modules", "lib")]
61
+ for name in files:
62
+ if not name.endswith(".md"):
63
+ continue
64
+ path = os.path.join(root, name)
65
+ rel = os.path.relpath(path, REPO)
66
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
67
+ text = f.read()
68
+
69
+ for target in LINK.findall(text):
70
+ if target.startswith(("http://", "https://", "mailto:")):
71
+ continue
72
+ file_part, _, anchor = target.partition("#")
73
+ file_part = file_part.split("?", 1)[0]
74
+
75
+ if file_part == "":
76
+ # 同文档锚点:指向"更靠下的小节标题"是常见写法,这里只校验它存在
77
+ target_path = path
78
+ else:
79
+ target_path = os.path.normpath(
80
+ os.path.join(os.path.dirname(path), file_part.replace("/", os.sep))
81
+ )
82
+ checked_files += 1
83
+ if not os.path.exists(target_path):
84
+ bad.append((rel, target, "文件不存在: " + os.path.relpath(target_path, REPO)))
85
+ continue
86
+
87
+ if anchor == "":
88
+ continue
89
+ if not target_path.endswith(".md"):
90
+ continue
91
+ checked_anchors += 1
92
+ if target_path not in anchor_cache:
93
+ anchor_cache[target_path] = anchors_of(target_path)
94
+ if anchor not in anchor_cache[target_path]:
95
+ bad.append(
96
+ (
97
+ rel,
98
+ target,
99
+ "锚点不存在: #" + anchor + " (该文档有 "
100
+ + str(len(anchor_cache[target_path]))
101
+ + " 个标题锚点)",
102
+ )
103
+ )
104
+
105
+ print(f"检查了 {checked_files} 个文件链接、{checked_anchors} 个锚点,坏链 {len(bad)} 个")
106
+ for src, target, why in bad:
107
+ print(f" BAD {src} -> {target} ({why})")
108
+ return 1 if bad else 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * doctor —— 一句话回答「DSH 又发版了,这个插件还能不能跑」。
4
+ *
5
+ * 逐个 import 这个插件依赖的 @deepseek-ai/* 包,检查源码真正用到的具名导出是否
6
+ * 还在,并打印每个包实际装的版本。类型层面的兼容性由 tsc 回答(npm run build)。
7
+ *
8
+ * 用法: node scripts/doctor.mjs
9
+ * 退出码 0 = 全部可用;1 = 至少一个包需要处理。
10
+ *
11
+ * 出现 ❌ 时按这个顺序处理:
12
+ * 1. 只是导出改名 → 把引用搬进 src/compat.ts(那里是唯一的边界适配层);
13
+ * 2. 是插件真用到的运行时 API 没了 → 适配代码,别去改 peer 范围;
14
+ * 3. 装错版本 → 在 profile 里对齐版本,而不是把本目录钉成旧版。
15
+ */
16
+ import { readFileSync, readdirSync } from 'node:fs'
17
+ import { createRequire } from 'node:module'
18
+ import { homedir } from 'node:os'
19
+ import { join } from 'node:path'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ /** 运行时真正 import 的名字;缺一个就是载荷失败。 */
23
+ const REQUIRED = {
24
+ '@deepseek-ai/cordis': ['Service'],
25
+ '@deepseek-ai/schemastery': ['default'],
26
+ '@deepseek-ai/dsh-tools': ['default', 'defineTool'],
27
+ '@deepseek-ai/dsh-llm': ['BlockAssembler', 'createUserMessage'],
28
+ // The only runtime dependency that is not the harness: without it the whole
29
+ // browser entry fails to load, before any DSH API question even matters.
30
+ 'playwright-core': ['chromium'],
31
+ }
32
+
33
+ /** 只有测试 / 开发路径用到的包;缺了只提醒,不算失败。 */
34
+ const OPTIONAL = {
35
+ '@deepseek-ai/dsh-system-prompt': ['default'],
36
+ '@deepseek-ai/dsh-session': [],
37
+ '@deepseek-ai/dsh-attachment': [],
38
+ '@deepseek-ai/dsh-agent': [],
39
+ }
40
+
41
+ const require = createRequire(import.meta.url)
42
+
43
+ /** Installed version of one package, or a marker when it is absent. */
44
+ function versionOf(pkg) {
45
+ try {
46
+ return JSON.parse(readFileSync(require.resolve(pkg + '/package.json'), 'utf8')).version
47
+ } catch {
48
+ return '(未安装)'
49
+ }
50
+ }
51
+
52
+ /** Probe one package and return the missing export names. */
53
+ async function probe(pkg, names) {
54
+ let mod
55
+ try {
56
+ mod = await import(pkg)
57
+ } catch (error) {
58
+ return { missing: names, module: undefined, fatal: String(error.message ?? error) }
59
+ }
60
+ return { missing: names.filter((name) => !(name in mod)), module: mod, fatal: undefined }
61
+ }
62
+
63
+ let failed = 0
64
+ console.log('依赖体检(运行时导出)')
65
+ console.log('')
66
+
67
+ for (const [pkg, names] of Object.entries(REQUIRED)) {
68
+ const { missing, fatal } = await probe(pkg, names)
69
+ const version = versionOf(pkg)
70
+ if (fatal !== undefined) {
71
+ failed += 1
72
+ console.log('❌ ' + pkg + ' ' + version)
73
+ console.log(' 导入失败: ' + fatal)
74
+ continue
75
+ }
76
+ if (missing.length > 0) {
77
+ failed += 1
78
+ console.log('❌ ' + pkg + ' ' + version + ' —— 缺少导出: ' + missing.join(', '))
79
+ console.log(' ' + (names.length > 0 ? '需要: ' + names.join(', ') : ''))
80
+ continue
81
+ }
82
+ console.log('✅ ' + pkg + ' ' + version + (names.length > 0 ? ' (' + names.join(', ') + ')' : ''))
83
+ }
84
+
85
+ console.log('')
86
+ console.log('可选依赖(测试 / 开发路径)')
87
+ for (const [pkg, names] of Object.entries(OPTIONAL)) {
88
+ const { missing, fatal } = await probe(pkg, names)
89
+ const version = versionOf(pkg)
90
+ if (fatal !== undefined || missing.length > 0) {
91
+ console.log(
92
+ '⚠️ ' + pkg + ' ' + version + (fatal !== undefined ? ' —— 导入失败' : ' —— 缺少: ' + missing.join(', ')),
93
+ )
94
+ continue
95
+ }
96
+ console.log('✅ ' + pkg + ' ' + version)
97
+ }
98
+
99
+ console.log('')
100
+ console.log('部署侧解析(profile 用 junction 挂这个插件,运行时到底从哪里取 playwright-core)')
101
+ const pluginDir = fileURLToPath(new URL('..', import.meta.url))
102
+ const profilesRoot = join(homedir(), '.dsh', 'profiles')
103
+ /** Resolve one package from one directory, the way Node would at load time. */
104
+ function resolveFrom(pkg, dir) {
105
+ try {
106
+ const resolved = createRequire(join(dir, 'package.json')).resolve(pkg + '/package.json')
107
+ return { path: resolved, version: JSON.parse(readFileSync(resolved, 'utf8')).version }
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
112
+ const anchors = [{ label: '插件目录', dir: pluginDir }]
113
+ try {
114
+ for (const name of readdirSync(profilesRoot)) {
115
+ if (name === 'node_modules') continue
116
+ anchors.push({ label: 'profile ' + name, dir: join(profilesRoot, name) })
117
+ }
118
+ } catch {
119
+ console.log('ℹ️ 没有 ' + profilesRoot + ':只检查插件自身')
120
+ }
121
+ let resolvedAnywhere = 0
122
+ for (const { label, dir } of anchors) {
123
+ const found = resolveFrom('playwright-core', dir)
124
+ if (found === undefined) {
125
+ // Junction 挂载时 Node 按插件真实路径解析,profile 目录里没有这个包是正常的;
126
+ // 只有"哪里都解析不到"才是部署级失败(下面的 resolvedAnywhere 判定)。
127
+ console.log('ℹ️ ' + label + ' 本目录没有 playwright-core(junction 会落到插件真实路径)—— ' + dir)
128
+ continue
129
+ }
130
+ resolvedAnywhere += 1
131
+ console.log('✅ ' + label + ' → playwright-core ' + found.version + ' ' + found.path)
132
+ }
133
+ if (resolvedAnywhere === 0) {
134
+ failed += 1
135
+ console.log('❌ 没有任何位置能解析 playwright-core:部署侧 browser entry 会在加载期直接失败')
136
+ }
137
+
138
+ console.log('')
139
+ if (failed === 0) {
140
+ console.log('全部可用。接着跑 `npm test` 与 `npm run verify` 确认行为没变。')
141
+ process.exit(0)
142
+ }
143
+ console.log(failed + ' 个包需要处理:优先把改名搬进 src/compat.ts,别去钉版本。')
144
+ process.exit(1)