publishport-opencli 1.8.5-pp.6 → 1.8.5-pp.8

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.
@@ -36,6 +36,9 @@
36
36
  * throttleMs 每张图之间的间隔毫秒,默认 250
37
37
  */
38
38
 
39
+ import { readFile } from 'node:fs/promises';
40
+ import { fileURLToPath } from 'node:url';
41
+
39
42
  const HTML_IMG_RE = /<img\b[^>]*?\ssrc=("|')(.*?)\1[^>]*>/gi;
40
43
  // Markdown 图片:![alt](url "title")。只取 url 部分,title/尺寸后缀忽略。
41
44
  const MD_IMG_RE = /!\[[^\]]*\]\(\s*<?([^)\s>]+)>?[^)]*\)/g;
@@ -182,6 +185,76 @@ export function buildTransferImagesJs(content, spec, skip) {
182
185
  );
183
186
  }
184
187
 
188
+ const MIME_BY_EXT = {
189
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
190
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
191
+ avif: 'image/avif', tif: 'image/tiff', tiff: 'image/tiff',
192
+ };
193
+
194
+ /**
195
+ * 判断一个图片 src 是否指向【本机文件系统的绝对路径】(而非可被浏览器 fetch 的 URL)。
196
+ * 页面内 fetch 只能解析 http(s)/data/blob 等 URL;本机绝对路径(如 /tmp/x.png)会被当成
197
+ * 站点相对 URL → 404。所以这类 src 必须在 Node 侧先读成 data: URI 再注入页面。纯函数。
198
+ */
199
+ export function isLocalImagePath(src) {
200
+ if (typeof src !== 'string' || !src) return false;
201
+ if (src.startsWith('file://')) return true;
202
+ if (/^[a-zA-Z]:[\\/]/.test(src)) return true; // Windows 盘符 C:\ 或 C:/
203
+ if (src.startsWith('//')) return false; // 协议相对 URL
204
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(src)) return false; // 其它 URI scheme(http: data: blob: …)
205
+ if (src.startsWith('/')) return true; // Unix 绝对路径
206
+ return false; // 相对路径视为站点相对,不当本机文件
207
+ }
208
+
209
+ function localPathFromSrc(src) {
210
+ if (src.startsWith('file://')) {
211
+ try { return fileURLToPath(src); } catch { return src.slice('file://'.length); }
212
+ }
213
+ return src;
214
+ }
215
+
216
+ /**
217
+ * Node 侧:把正文里引用的【本机图片路径】读成 base64 data: URI,原地替换回正文。
218
+ * 这样注入页面后,平台的图片转存(binary-multipart / 自定义 uploadFn)就能 fetch 到字节
219
+ * 并转存到平台图床——否则本机路径在页面里会被当成站点相对 URL、直接 404(图全裂)。
220
+ * 只处理本机绝对路径;http(s)/data/相对 URL 一律原样保留,交给页面内转存或平台自处理。
221
+ *
222
+ * @param {string} content 文章正文(Markdown 或 HTML)
223
+ * @param {{ readFile?: (p: string) => Promise<Uint8Array|Buffer> }} [deps] 便于测试注入
224
+ * @returns {Promise<{ content: string, inlined: Array<{src:string,bytes:number,mime:string}>, missing: Array<{src:string,error:string}> }>}
225
+ */
226
+ export async function inlineLocalImages(content, deps = {}) {
227
+ const read = deps.readFile || readFile;
228
+ const inlined = [];
229
+ const missing = [];
230
+ if (typeof content !== 'string' || !content) return { content: content || '', inlined, missing };
231
+ const refs = extractImageRefs(content);
232
+ const cache = {};
233
+ let out = content;
234
+ for (const ref of refs) {
235
+ const src = ref.src;
236
+ if (!isLocalImagePath(src)) continue;
237
+ if (!(src in cache)) {
238
+ try {
239
+ const p = localPathFromSrc(src);
240
+ const buf = await read(p);
241
+ const bytes = buf.byteLength != null ? buf.byteLength : buf.length;
242
+ const ext = (p.split(/[\\/]/).pop().split('.').pop() || '').toLowerCase().split('?')[0];
243
+ const mime = MIME_BY_EXT[ext] || 'image/png';
244
+ const b64 = Buffer.from(buf).toString('base64');
245
+ cache[src] = `data:${mime};base64,${b64}`;
246
+ inlined.push({ src, bytes, mime });
247
+ } catch (e) {
248
+ cache[src] = null;
249
+ missing.push({ src, error: String((e && e.message) || e) });
250
+ }
251
+ }
252
+ const du = cache[src];
253
+ if (du) out = out.split(ref.full).join(ref.full.replace(src, du));
254
+ }
255
+ return { content: out, inlined, missing };
256
+ }
257
+
185
258
  /**
186
259
  * 在平台页面里把正文图片全部转存到本平台图床,返回改写后的正文 + 转存报告。
187
260
  * 调用前 page 必须已经导航到该平台的写作 origin(cookie / Origin 才正确)。
@@ -208,6 +281,8 @@ export const __test__ = {
208
281
  extractImageRefs,
209
282
  pickPath,
210
283
  buildTransferImagesJs,
284
+ isLocalImagePath,
285
+ inlineLocalImages,
211
286
  HTML_IMG_RE,
212
287
  MD_IMG_RE,
213
288
  };
@@ -4,6 +4,8 @@ import {
4
4
  pickPath,
5
5
  buildTransferImagesJs,
6
6
  transferImages,
7
+ isLocalImagePath,
8
+ inlineLocalImages,
7
9
  } from './images.js';
8
10
 
9
11
  /**
@@ -180,3 +182,50 @@ describe('transferImages (无 spec / 无图 短路)', () => {
180
182
  expect(out.content).toBe('纯文本');
181
183
  });
182
184
  });
185
+
186
+ describe('isLocalImagePath', () => {
187
+ it('本机绝对路径 / file:// / Windows 盘符判为本机', () => {
188
+ expect(isLocalImagePath('/tmp/x.png')).toBe(true);
189
+ expect(isLocalImagePath('file:///tmp/x.png')).toBe(true);
190
+ expect(isLocalImagePath('C:\\imgs\\x.png')).toBe(true);
191
+ expect(isLocalImagePath('C:/imgs/x.png')).toBe(true);
192
+ });
193
+ it('URL / data / 协议相对 / 站点相对裸名一律不是本机', () => {
194
+ expect(isLocalImagePath('https://cdn/x.png')).toBe(false);
195
+ expect(isLocalImagePath('http://cdn/x.png')).toBe(false);
196
+ expect(isLocalImagePath('data:image/png;base64,AAAA')).toBe(false);
197
+ expect(isLocalImagePath('blob:https://a/b')).toBe(false);
198
+ expect(isLocalImagePath('//cdn/x.png')).toBe(false);
199
+ expect(isLocalImagePath('x.png')).toBe(false);
200
+ expect(isLocalImagePath('')).toBe(false);
201
+ expect(isLocalImagePath(null)).toBe(false);
202
+ });
203
+ });
204
+
205
+ describe('inlineLocalImages', () => {
206
+ const fakeRead = async (p) => {
207
+ if (p === '/tmp/ok.png') return Buffer.from([0x89, 0x50, 0x4e, 0x47]);
208
+ throw new Error('ENOENT');
209
+ };
210
+ it('本机图片读成 data: URI 并原地替换,URL 保持不动', async () => {
211
+ const md = '![a](/tmp/ok.png) 和 ![b](https://cdn/keep.png)';
212
+ const r = await inlineLocalImages(md, { readFile: fakeRead });
213
+ expect(r.content).toContain('data:image/png;base64,');
214
+ expect(r.content).toContain('https://cdn/keep.png'); // 远程 URL 不动
215
+ expect(r.content).not.toContain('/tmp/ok.png');
216
+ expect(r.inlined).toHaveLength(1);
217
+ expect(r.inlined[0].mime).toBe('image/png');
218
+ expect(r.missing).toHaveLength(0);
219
+ });
220
+ it('读不到的本机图片进 missing,原路径保留', async () => {
221
+ const r = await inlineLocalImages('![x](/tmp/nope.jpg)', { readFile: fakeRead });
222
+ expect(r.missing).toHaveLength(1);
223
+ expect(r.missing[0].src).toBe('/tmp/nope.jpg');
224
+ expect(r.content).toContain('/tmp/nope.jpg'); // 失败则原样保留
225
+ });
226
+ it('HTML <img> 本机 src 同样处理', async () => {
227
+ const r = await inlineLocalImages('<img src="/tmp/ok.png" alt="k">', { readFile: fakeRead });
228
+ expect(r.content).toContain('data:image/png;base64,');
229
+ expect(r.content).toContain('alt="k"'); // 其余属性保留
230
+ });
231
+ });
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { CommandExecutionError } from '@jackwener/opencli/errors';
20
20
  import { normalizeContent } from './format.js';
21
+ import { inlineLocalImages } from './images.js';
21
22
  import { PAGE_RUNTIME } from './page-runtime.js';
22
23
 
23
24
  /**
@@ -146,15 +147,30 @@ export async function publishArticle(page, args) {
146
147
  if (typeof profile.publish !== 'function') throw new Error('publishArticle: profile.publish must be a function');
147
148
 
148
149
  const norm = normalizeContent(body, { format });
149
- const content = profile.outputFormat === 'markdown' ? norm.markdown : norm.html;
150
+
151
+ // Node 侧:正文里引用的【本机图片路径】页面内 fetch 不到(会被当成站点相对 URL → 404),
152
+ // 先读成 data: URI 再注入页面,交给平台的图片转存把它上传到平台图床。
153
+ const mdInlined = await inlineLocalImages(norm.markdown);
154
+ const htmlInlined = await inlineLocalImages(norm.html);
155
+ const markdown = mdInlined.content;
156
+ const html = htmlInlined.content;
157
+ const content = profile.outputFormat === 'markdown' ? markdown : html;
158
+ // 合并两份的「本机图片读取失败」(按 src 去重),供命令层据此把结果判为 partial。
159
+ const localMissing = [];
160
+ const seenMissing = new Set();
161
+ for (const m of [...mdInlined.missing, ...htmlInlined.missing]) {
162
+ if (seenMissing.has(m.src)) continue;
163
+ seenMissing.add(m.src);
164
+ localMissing.push(m);
165
+ }
150
166
 
151
167
  await gotoWritePage(page, profile.home, profile.originRe);
152
168
 
153
169
  const ctx = {
154
170
  title,
155
171
  content,
156
- markdown: norm.markdown,
157
- html: norm.html,
172
+ markdown,
173
+ html,
158
174
  draftOnly: !!draftOnly,
159
175
  outputFormat: profile.outputFormat,
160
176
  preprocessConfig: profile.preprocessConfig || null,
@@ -175,7 +191,11 @@ export async function publishArticle(page, args) {
175
191
  id: result.id,
176
192
  url: result.url,
177
193
  draft: result.draft,
178
- images: { uploaded: result.uploaded || [], failed: result.failed || [] },
194
+ // 图片失败 = 页面内转存失败(result.failed) + Node 侧本机图片读取失败(localMissing)。
195
+ images: {
196
+ uploaded: result.uploaded || [],
197
+ failed: (result.failed || []).concat(localMissing),
198
+ },
179
199
  };
180
200
  }
181
201
 
@@ -34,8 +34,8 @@ async function resolvePayload(kwargs) {
34
34
  return resolved;
35
35
  }
36
36
 
37
- function buildResultRow(message, targetType, target, outcome, extra = {}) {
38
- return [{ status: 'success', outcome, message, target_type: targetType, target, ...extra }];
37
+ function buildResultRow(message, targetType, target, outcome, extra = {}, status = 'success') {
38
+ return [{ status, outcome, message, target_type: targetType, target, ...extra }];
39
39
  }
40
40
 
41
41
  // ── CSDN 平台 profile ─────────────────────────────────────────────────────────
@@ -110,13 +110,20 @@ const csdnProfile = {
110
110
 
111
111
  // 步骤一:下载图片字节
112
112
  var imageResponse = await fetch(src, { credentials: 'omit' });
113
- if (!imageResponse.ok) throw new Error('图片下载失败: ' + src);
113
+ if (!imageResponse.ok) throw new Error('图片下载失败: ' + src.slice(0, 80));
114
114
  var imageBlob = await imageResponse.blob();
115
115
 
116
- // 步骤二:获取文件扩展名
117
- var ext = (src.split('.').pop() || 'jpg').toLowerCase().split('?')[0];
116
+ // 步骤二:确定扩展名。优先从 URL 后缀猜;本机图片是以 data: URI 传进来的(无文件名),
117
+ // 后缀猜不出时退回 blob.type(如 image/png → png)。
118
118
  var validExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
119
- var validExt = validExts.indexOf(ext) !== -1 ? ext : 'jpg';
119
+ var ext = (src.split('?')[0].split('.').pop() || '').toLowerCase();
120
+ if (validExts.indexOf(ext) === -1) {
121
+ var mt = (imageBlob.type || '').toLowerCase(); // e.g. image/png
122
+ var sub = mt.indexOf('/') !== -1 ? mt.split('/')[1] : '';
123
+ if (sub === 'jpeg') sub = 'jpg';
124
+ ext = validExts.indexOf(sub) !== -1 ? sub : '';
125
+ }
126
+ var validExt = ext || 'jpg';
120
127
 
121
128
  // 步骤三:向 CSDN 请求上传签名
122
129
  var signApiPath = '/resource-api/v1/image/direct/upload/signature';
@@ -372,12 +379,25 @@ cli({
372
379
  if (upN || failN) {
373
380
  message += `·图片:${upN} 张转存成功${failN ? `,${failN} 张失败` : ''}`;
374
381
  }
382
+ // 有图片没转存成功 = 部分成功:正文已发/存草稿,但对应图片会裂。醒目提示 + status=partial,
383
+ // 避免云端 AI 把它当成功而漏掉图(正文与图片是一体的,缺图不算完整发布)。
384
+ if (failN > 0) {
385
+ const detail = result.images.failed
386
+ .map((f) => (f && f.src) || '').filter(Boolean).slice(0, 5).join(';');
387
+ message += `。⚠️ 有图片未成功转存(正文里这些图会裂),请检查图片来源后重发:${detail}`;
388
+ }
375
389
  return buildResultRow(
376
390
  message,
377
391
  'article',
378
392
  '',
379
393
  result.draft ? 'draft' : 'created',
380
- { created_target: 'article:' + result.id, created_url: result.url },
394
+ {
395
+ created_target: 'article:' + result.id,
396
+ created_url: result.url,
397
+ images_uploaded: upN,
398
+ images_failed: failN,
399
+ },
400
+ failN > 0 ? 'partial' : 'success',
381
401
  );
382
402
  },
383
403
  });
@@ -6,13 +6,10 @@ async function hasZhihuAuthCookie(page) {
6
6
  return cookies.some(c => c.name === 'z_c0' && c.value);
7
7
  }
8
8
 
9
- async function verifyZhihuIdentity(page) {
10
- if (!await hasZhihuAuthCookie(page)) {
11
- throw new AuthRequiredError('www.zhihu.com', 'Zhihu z_c0 cookie missing — anonymous');
12
- }
9
+ async function probeZhihuMe(page) {
13
10
  await page.goto('https://www.zhihu.com/');
14
11
  await page.wait(2);
15
- const data = await page.evaluate(`
12
+ return page.evaluate(`
16
13
  (async () => {
17
14
  try {
18
15
  const r = await fetch('https://www.zhihu.com/api/v4/me?include=url_token', { credentials: 'include' });
@@ -23,6 +20,19 @@ async function verifyZhihuIdentity(page) {
23
20
  }
24
21
  })()
25
22
  `);
23
+ }
24
+
25
+ async function verifyZhihuIdentity(page) {
26
+ if (!await hasZhihuAuthCookie(page)) {
27
+ throw new AuthRequiredError('www.zhihu.com', 'Zhihu z_c0 cookie missing — anonymous');
28
+ }
29
+ let data = await probeZhihuMe(page);
30
+ // 冷启动现象:z_c0 cookie 明明在,首次 /api/v4/me 却偶发 401/403(浏览器会话未热身)。
31
+ // 既然本地已有有效登录 cookie,就不该据此判定为匿名——短暂等待后重试一次再下结论。
32
+ if (data && (data.__httpError === 401 || data.__httpError === 403)) {
33
+ await page.wait(2);
34
+ data = await probeZhihuMe(page);
35
+ }
26
36
  if (data?.__exception) {
27
37
  throw new CommandExecutionError(`Zhihu whoami failed: ${data.__exception}`);
28
38
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 在机器级浏览器会话锁的保护下执行 fn。并发调用会排队串行;顺序调用无等待。
3
+ * fn 抛出会原样冒泡,锁始终在 finally 里释放。
4
+ */
5
+ export declare function withBrowserSessionLock<T>(fn: () => Promise<T>): Promise<T>;
6
+ /** 条件加锁:locked 为真时走串行锁,否则直接执行(前台/交互命令用独立窗口,无需串行)。 */
7
+ export declare function withBrowserSessionLockIf<T>(locked: boolean, fn: () => Promise<T>): Promise<T>;
8
+ export declare const __lockInternals: {
9
+ LOCK_PATH: string;
10
+ ACQUIRE_TIMEOUT_MS: number;
11
+ STALE_AFTER_MS: number;
12
+ };
@@ -0,0 +1,118 @@
1
+ // [pp-only] 机器级浏览器会话串行锁。
2
+ //
3
+ // PublishPort 的后台自动化窗口物理上一次只能安全服务一条命令:多条命令并发驱动同一个
4
+ // 窗口时,官方扩展的 rebind/关窗逻辑会把正在跑的命令的调试器扯掉(表现为
5
+ // `Debugger is not attached` / `Detached while handling command`),还会 race 出多余窗口。
6
+ // 由于并发的浏览器命令是各自独立的 opencli 进程,进程内的锁不够,必须跨进程串行。
7
+ //
8
+ // 这里用一个 O_EXCL 锁文件做机器级互斥:并发命令排队,一条条跑完,复用同一个窗口,
9
+ // 既不 churn 也不互相拆台。顺序命令锁立即可得、无影响。
10
+ //
11
+ // 稳定性要点(针对历史上「陈旧锁导致持续 DEVICE_BUSY」的坑):
12
+ // - 锁文件写入持有者 PID;等待方发现持有者进程已死(process.kill(pid,0) 抛 ESRCH)
13
+ // 立即抢占,不会被崩溃残留的锁永久卡死;
14
+ // - 再加一层 mtime 兜底:锁文件过老(默认 10min)也视为陈旧,防 PID 复用的极端情况;
15
+ // - 进程正常退出 finally 删锁;异常退出靠上面两层兜底自愈。
16
+ import { openSync, closeSync, writeSync, readFileSync, unlinkSync, statSync, mkdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ const LOCK_DIR = join(homedir(), '.opencli');
20
+ const LOCK_PATH = join(LOCK_DIR, 'browser-adapter-session.lock');
21
+ // 等待前面排队命令的最长时间。设得比单条浏览器命令的运行上限更宽,好让后面的命令
22
+ // 排到队即可,而不是提前放弃。持有者若卡死,会先命中它自己命令的超时而释放锁。
23
+ const ACQUIRE_TIMEOUT_MS = 180_000;
24
+ // mtime 兜底:锁文件比这更老就视为陈旧可抢(正常命令远到不了这个量级)。
25
+ const STALE_AFTER_MS = 10 * 60_000;
26
+ const POLL_MS = 60;
27
+ function sleep(ms) {
28
+ return new Promise((resolve) => setTimeout(resolve, ms));
29
+ }
30
+ function isAlive(pid) {
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ }
35
+ catch (err) {
36
+ // ESRCH = 进程不存在;EPERM = 存在但无权限发信号(仍算活着)。
37
+ return err?.code === 'EPERM';
38
+ }
39
+ }
40
+ function readLockPid() {
41
+ try {
42
+ const pid = parseInt(readFileSync(LOCK_PATH, 'utf8').trim(), 10);
43
+ return Number.isFinite(pid) ? pid : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /** 原子占锁:O_EXCL 创建成功即获得锁。已存在返回 false(不抛)。 */
50
+ function tryAcquire() {
51
+ try {
52
+ const fd = openSync(LOCK_PATH, 'wx');
53
+ try {
54
+ writeSync(fd, String(process.pid));
55
+ }
56
+ finally {
57
+ closeSync(fd);
58
+ }
59
+ return true;
60
+ }
61
+ catch (err) {
62
+ if (err?.code === 'EEXIST')
63
+ return false;
64
+ throw err;
65
+ }
66
+ }
67
+ /** 持有者已死 / 锁文件过老 → 删掉陈旧锁,让后续 tryAcquire 抢占。 */
68
+ function reapStaleLock() {
69
+ try {
70
+ const pid = readLockPid();
71
+ const age = Date.now() - statSync(LOCK_PATH).mtimeMs;
72
+ if ((pid !== null && !isAlive(pid)) || age > STALE_AFTER_MS) {
73
+ unlinkSync(LOCK_PATH);
74
+ }
75
+ }
76
+ catch {
77
+ // 锁文件刚被别人释放/删除:忽略,下一轮 tryAcquire 会重试。
78
+ }
79
+ }
80
+ /**
81
+ * 在机器级浏览器会话锁的保护下执行 fn。并发调用会排队串行;顺序调用无等待。
82
+ * fn 抛出会原样冒泡,锁始终在 finally 里释放。
83
+ */
84
+ export async function withBrowserSessionLock(fn) {
85
+ try {
86
+ mkdirSync(LOCK_DIR, { recursive: true });
87
+ }
88
+ catch {
89
+ // 目录已存在或无法创建(后者会在 openSync 时暴露真实错误)。
90
+ }
91
+ const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
92
+ while (!tryAcquire()) {
93
+ reapStaleLock();
94
+ if (tryAcquire())
95
+ break;
96
+ if (Date.now() >= deadline) {
97
+ throw new Error(`browser session lock busy: waited ${Math.round(ACQUIRE_TIMEOUT_MS / 1000)}s for another browser command to finish`);
98
+ }
99
+ await sleep(POLL_MS);
100
+ }
101
+ try {
102
+ return await fn();
103
+ }
104
+ finally {
105
+ try {
106
+ unlinkSync(LOCK_PATH);
107
+ }
108
+ catch {
109
+ // 已被陈旧抢占逻辑删除或从未创建:无所谓。
110
+ }
111
+ }
112
+ }
113
+ /** 条件加锁:locked 为真时走串行锁,否则直接执行(前台/交互命令用独立窗口,无需串行)。 */
114
+ export async function withBrowserSessionLockIf(locked, fn) {
115
+ return locked ? withBrowserSessionLock(fn) : fn();
116
+ }
117
+ // 供测试用的常量导出。
118
+ export const __lockInternals = { LOCK_PATH, ACQUIRE_TIMEOUT_MS, STALE_AFTER_MS };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { withBrowserSessionLock, withBrowserSessionLockIf, __lockInternals } from './browser-session-lock.js';
4
+ const LOCK = __lockInternals.LOCK_PATH;
5
+ function cleanupLock() {
6
+ try {
7
+ if (existsSync(LOCK))
8
+ unlinkSync(LOCK);
9
+ }
10
+ catch { /* ignore */ }
11
+ }
12
+ describe('browser-session-lock', () => {
13
+ afterEach(cleanupLock);
14
+ it('serializes concurrent holders — no overlap', async () => {
15
+ const events = [];
16
+ let active = 0;
17
+ let maxActive = 0;
18
+ const task = (id) => withBrowserSessionLock(async () => {
19
+ active++;
20
+ maxActive = Math.max(maxActive, active);
21
+ events.push(`enter:${id}`);
22
+ await new Promise((r) => setTimeout(r, 30));
23
+ events.push(`exit:${id}`);
24
+ active--;
25
+ });
26
+ await Promise.all([task('a'), task('b'), task('c')]);
27
+ // 任一时刻只有一个持有者 → 临界区不重叠。
28
+ expect(maxActive).toBe(1);
29
+ // enter/exit 必须严格配对相邻(a 进 a 出、b 进 b 出…),不会交错。
30
+ for (let i = 0; i < events.length; i += 2) {
31
+ expect(events[i]).toMatch(/^enter:/);
32
+ expect(events[i + 1]).toMatch(/^exit:/);
33
+ expect(events[i].split(':')[1]).toBe(events[i + 1].split(':')[1]);
34
+ }
35
+ // 锁在最后被释放。
36
+ expect(existsSync(LOCK)).toBe(false);
37
+ });
38
+ it('releases the lock even when the body throws', async () => {
39
+ await expect(withBrowserSessionLock(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
40
+ expect(existsSync(LOCK)).toBe(false);
41
+ });
42
+ it('steals a stale lock whose owner PID is dead', async () => {
43
+ // 写一个持有者 PID = 一个几乎不可能存在的进程号的锁文件(模拟崩溃残留)。
44
+ writeFileSync(LOCK, '2147483646');
45
+ let ran = false;
46
+ await withBrowserSessionLock(async () => { ran = true; });
47
+ expect(ran).toBe(true);
48
+ expect(existsSync(LOCK)).toBe(false);
49
+ });
50
+ it('withBrowserSessionLockIf(false) runs without touching the lock file', async () => {
51
+ let ran = false;
52
+ await withBrowserSessionLockIf(false, async () => {
53
+ ran = true;
54
+ // 未加锁:临界区内锁文件不应存在。
55
+ expect(existsSync(LOCK)).toBe(false);
56
+ });
57
+ expect(ran).toBe(true);
58
+ });
59
+ it('withBrowserSessionLockIf(true) does acquire the lock', async () => {
60
+ let sawLock = false;
61
+ await withBrowserSessionLockIf(true, async () => {
62
+ sawLock = existsSync(LOCK);
63
+ });
64
+ expect(sawLock).toBe(true);
65
+ expect(existsSync(LOCK)).toBe(false);
66
+ });
67
+ });
package/dist/src/cli.js CHANGED
@@ -32,7 +32,7 @@ import { buildHtmlTreeJs } from './browser/html-tree.js';
32
32
  import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
33
33
  import { analyzeSite } from './browser/analyze.js';
34
34
  import { registerAuthCommands } from './commands/auth.js';
35
- import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js';
35
+ import { daemonRestart, daemonStatus, daemonStop, daemonWarm } from './commands/daemon.js';
36
36
  import { log } from './logger.js';
37
37
  import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js';
38
38
  import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js';
@@ -3155,6 +3155,11 @@ cli({
3155
3155
  .command('restart')
3156
3156
  .description('Restart the daemon')
3157
3157
  .action(async () => { await daemonRestart(); });
3158
+ daemonCmd
3159
+ .command('warm')
3160
+ .description('Pre-open the background automation window (keeps a tab alive for reuse)')
3161
+ .option('--url <url>', 'http(s) info page to keep in the warm tab (falls back to about:blank)')
3162
+ .action(async (opts) => { await daemonWarm(opts.url); });
3158
3163
  // ── External CLIs ─────────────────────────────────────────────────────────
3159
3164
  const externalClis = loadExternalClis();
3160
3165
  const externalCmd = program
@@ -55,7 +55,7 @@ describe('createProgram root help descriptions', () => {
55
55
  expect(descriptionFor(program, 'plugin')).toBe('create, install, list, uninstall, update');
56
56
  expect(descriptionFor(program, 'adapter')).toBe('eject, reset, status');
57
57
  expect(descriptionFor(program, 'profile')).toBe('list, rename, use');
58
- expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
58
+ expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop, warm');
59
59
  expect(descriptionFor(program, 'external')).toBe('install, list, register');
60
60
  });
61
61
  it('renders auth namespace structured help', () => {
@@ -623,11 +623,11 @@ describe('createProgram root help descriptions', () => {
623
623
  command: 'opencli daemon',
624
624
  usage: 'opencli daemon <command> [args] [options]',
625
625
  description: 'Manage the opencli daemon',
626
- command_count: 3,
626
+ command_count: 4,
627
627
  namespace_options: [],
628
628
  structured_help: { usage: 'opencli daemon --help -f yaml' },
629
629
  });
630
- expect(data.commands.map((cmd) => cmd.name)).toEqual(['restart', 'status', 'stop']);
630
+ expect(data.commands.map((cmd) => cmd.name)).toEqual(['restart', 'status', 'stop', 'warm']);
631
631
  expect(data.global_options.map((option) => option.name)).toEqual(expect.arrayContaining(['version', 'profile']));
632
632
  }
633
633
  finally {
@@ -222,6 +222,16 @@ function refreshRowForError(site, entry, error) {
222
222
  error: code ? `${code}: ${message}` : message,
223
223
  };
224
224
  }
225
+ // [pp-only] auth 探测默认走 persistent —— 桌面每 20s 的 quick/full 探测若用 ephemeral
226
+ // 会爆一批临时窗口又关,是「窗口不停开又关」的一大来源。persistent 让轮询复用同一批
227
+ // 常驻标签(每平台一个,官方扩展永不 idle 关闭),零 churn。keepTab 跟随 session。
228
+ // 与 resolveSiteSession 一致:默认 persistent,不依赖 env;仅
229
+ // PUBLISHPORT_SITE_SESSION=ephemeral 时才显式退回一次性会话。
230
+ function authProbeSessionOpts() {
231
+ return process.env.PUBLISHPORT_SITE_SESSION === 'ephemeral'
232
+ ? { siteSession: 'ephemeral', keepTab: 'false' }
233
+ : { siteSession: 'persistent', keepTab: 'true' };
234
+ }
225
235
  async function runQuick(cmd, opts) {
226
236
  try {
227
237
  const loaded = await loadLazyCommand(cmd);
@@ -247,8 +257,7 @@ async function runQuick(cmd, opts) {
247
257
  };
248
258
  }
249
259
  const result = await executeCommand(quickCmd, { timeout: opts.timeoutSeconds }, false, {
250
- siteSession: 'ephemeral',
251
- keepTab: 'false',
260
+ ...authProbeSessionOpts(),
252
261
  windowMode: 'background',
253
262
  ...(opts.profile ? { profile: opts.profile } : {}),
254
263
  });
@@ -277,8 +286,7 @@ async function runFull(cmd, opts) {
277
286
  const loaded = await loadLazyCommand(cmd);
278
287
  const fullCmd = withTimeoutArg(loaded, opts.timeoutSeconds);
279
288
  const result = await executeCommand(fullCmd, { timeout: opts.timeoutSeconds }, false, {
280
- siteSession: 'ephemeral',
281
- keepTab: 'false',
289
+ ...authProbeSessionOpts(),
282
290
  windowMode: 'background',
283
291
  ...(opts.profile ? { profile: opts.profile } : {}),
284
292
  });
@@ -3,7 +3,23 @@
3
3
  * opencli daemon status — show daemon state
4
4
  * opencli daemon stop — graceful shutdown
5
5
  * opencli daemon restart — graceful shutdown, then start a fresh daemon
6
+ * opencli daemon warm — [pp-only] 预热后台自动化窗口(挂一个持久空白标签)
6
7
  */
7
8
  export declare function daemonStatus(): Promise<void>;
8
9
  export declare function daemonStop(): Promise<void>;
9
10
  export declare function daemonRestart(): Promise<void>;
11
+ /**
12
+ * [pp-only] 预热后台自动化窗口。
13
+ *
14
+ * 用一个持久(persistent)会话在后台打开自动化窗口并挂一个标签,让「后台常驻窗口」在
15
+ * **任何真实命令之前**就已经存在——之后各平台命令只是往这个窗口里加/复用标签,永远不会
16
+ * 「一有动作就弹一个新窗口」。桌面客户端在启动、且检测到扩展已连接后调用一次即可。
17
+ *
18
+ * `url` 传一个说明页(如 https://publishport.app/automation)时,这个常驻标签会停在
19
+ * 说明页上,告诉用户「此窗口用于自动化、请勿关闭」——用户不关它,Chrome 与扩展就一直
20
+ * 在线,PublishPort 也能稳定检测到浏览器。不传则回退到 about:blank。
21
+ *
22
+ * 窗口走 background 模式(不聚焦、不抢焦点),标签因 persistent 永不 idle 关闭,会一直
23
+ * 挂着。重复调用是幂等的(复用同一个已存在的窗口/标签)。
24
+ */
25
+ export declare function daemonWarm(url?: string): Promise<void>;
@@ -3,6 +3,7 @@
3
3
  * opencli daemon status — show daemon state
4
4
  * opencli daemon stop — graceful shutdown
5
5
  * opencli daemon restart — graceful shutdown, then start a fresh daemon
6
+ * opencli daemon warm — [pp-only] 预热后台自动化窗口(挂一个持久空白标签)
6
7
  */
7
8
  import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
8
9
  import { restartDaemon } from '../browser/daemon-lifecycle.js';
@@ -10,6 +11,7 @@ import { formatDuration } from '../download/progress.js';
10
11
  import { log } from '../logger.js';
11
12
  import { PKG_VERSION } from '../version.js';
12
13
  import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js';
14
+ import { browserSession, getBrowserFactory } from '../runtime.js';
13
15
  export async function daemonStatus() {
14
16
  const status = await fetchDaemonStatus();
15
17
  if (!status) {
@@ -96,3 +98,43 @@ export async function daemonRestart() {
96
98
  log.warn('Daemon is running, but the Browser Bridge extension has not connected yet.');
97
99
  }
98
100
  }
101
+ /**
102
+ * [pp-only] 预热后台自动化窗口。
103
+ *
104
+ * 用一个持久(persistent)会话在后台打开自动化窗口并挂一个标签,让「后台常驻窗口」在
105
+ * **任何真实命令之前**就已经存在——之后各平台命令只是往这个窗口里加/复用标签,永远不会
106
+ * 「一有动作就弹一个新窗口」。桌面客户端在启动、且检测到扩展已连接后调用一次即可。
107
+ *
108
+ * `url` 传一个说明页(如 https://publishport.app/automation)时,这个常驻标签会停在
109
+ * 说明页上,告诉用户「此窗口用于自动化、请勿关闭」——用户不关它,Chrome 与扩展就一直
110
+ * 在线,PublishPort 也能稳定检测到浏览器。不传则回退到 about:blank。
111
+ *
112
+ * 窗口走 background 模式(不聚焦、不抢焦点),标签因 persistent 永不 idle 关闭,会一直
113
+ * 挂着。重复调用是幂等的(复用同一个已存在的窗口/标签)。
114
+ */
115
+ export async function daemonWarm(url) {
116
+ const status = await fetchDaemonStatus();
117
+ if (!status) {
118
+ // 没连上扩展时 browserSession 会自行拉起 daemon 并等待;但扩展没连上就没法建窗,
119
+ // 这里只提示、不报错,交由桌面在扩展就绪后重试。
120
+ log.warn('Daemon/extension not ready; skip warm (retry after the browser extension connects).');
121
+ return;
122
+ }
123
+ if (!status.extensionConnected) {
124
+ log.warn('Browser extension not connected yet; skip warm.');
125
+ return;
126
+ }
127
+ // 扩展只放行 http/https 导航;其它一律回退 about:blank,避免报错。
128
+ const target = url && /^https?:\/\//i.test(url) ? url : 'about:blank';
129
+ const BrowserFactory = getBrowserFactory('__warm__');
130
+ try {
131
+ await browserSession(BrowserFactory, async (page) => {
132
+ // 一次导航即可强制扩展创建自动化窗口 + 标签并登记 persistent 租约。
133
+ await page.goto(target).catch(() => { });
134
+ }, { session: 'site:__warm__', windowMode: 'background', surface: 'adapter', siteSession: 'persistent' });
135
+ log.success(`Automation window warmed (${target}, kept alive for reuse).`);
136
+ }
137
+ catch (err) {
138
+ log.warn(`Warm failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
139
+ }
140
+ }
@@ -18,6 +18,7 @@ import { executePipeline } from './pipeline/index.js';
18
18
  import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js';
19
19
  import { shouldUseBrowserSession } from './capabilityRouting.js';
20
20
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
21
+ import { withBrowserSessionLockIf } from './browser-session-lock.js';
21
22
  import { resolveProfileContextId } from './browser/profile.js';
22
23
  import { emitHook } from './hooks.js';
23
24
  import { log } from './logger.js';
@@ -219,7 +220,10 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
219
220
  const session = resolveAdapterBrowserSession(cmd, siteSession);
220
221
  const keepTab = resolveKeepTab(siteSession, opts.keepTab);
221
222
  const windowMode = resolveBrowserWindowMode(cmd.defaultWindowMode ?? 'background', opts.windowMode);
222
- result = await browserSession(BrowserFactory, async (page) => {
223
+ // [pp-only] 后台自动化命令共用同一个窗口,物理上一次只能安全跑一条——用机器级锁
224
+ // 把并发命令串行化(排队复用同一窗口),避免互相拆窗/扯掉调试器。前台/交互命令走
225
+ // 独立窗口,不参与串行。见 browser-session-lock.ts。
226
+ result = await withBrowserSessionLockIf(windowMode !== 'foreground', () => browserSession(BrowserFactory, async (page) => {
223
227
  const observation = traceMode === 'off'
224
228
  ? null
225
229
  : new ObservationSession({
@@ -334,7 +338,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
334
338
  await page.closeWindow?.().catch(() => { });
335
339
  throw err;
336
340
  }
337
- }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession });
341
+ }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession }));
338
342
  }
339
343
  else {
340
344
  // Non-browser commands: enforce a timeout only when the command exposes
@@ -451,8 +455,26 @@ function normalizeSiteSession(raw) {
451
455
  return raw;
452
456
  throw new ArgumentError(`--site-session must be one of: ephemeral, persistent. Received: "${String(raw)}"`);
453
457
  }
458
+ // [pp-only] PublishPort 是「单机单用户 + 复用真实登录态」模型:它希望所有浏览器
459
+ // 命令长期复用同一个后台自动化窗口,而不是上游默认的 ephemeral(每条命令开标签→
460
+ // 干活→30s idle 后关标签,AI 命令间隔一超过 30s 就变成「窗口又开又关」)。
461
+ // 官方扩展对 persistent 会话本来就永不 idle 关闭、且 SW 重启后能从存活标签反查回
462
+ // 窗口复用——所以只要把默认会话抬成 persistent,无需改扩展即可复用窗口。
463
+ //
464
+ // 关键:默认就是 persistent,**不依赖任何环境变量**。早先版本靠桌面注入
465
+ // PUBLISHPORT_SITE_SESSION=persistent 才开启,实测太脆——同一台机可能同时跑着
466
+ // 「注入了 env 的 dev 构建」和「没注入的正式构建」,后者 fallback 回 ephemeral 照样
467
+ // churn。所以直接把 fork 默认焊成 persistent;只保留 PUBLISHPORT_SITE_SESSION=ephemeral
468
+ // 作为显式反向开关(极少用),供个别场景强制回到一次性会话。
469
+ // 优先级:显式 `--site-session` flag > 环境变量(仅用于强制 ephemeral)> 适配器 cmd.siteSession > 'persistent'。
470
+ function envDefaultSiteSession() {
471
+ const raw = process.env.PUBLISHPORT_SITE_SESSION;
472
+ if (raw === 'persistent' || raw === 'ephemeral')
473
+ return raw;
474
+ return null;
475
+ }
454
476
  function resolveSiteSession(cmd, rawOption) {
455
- return normalizeSiteSession(rawOption) ?? cmd.siteSession ?? 'ephemeral';
477
+ return normalizeSiteSession(rawOption) ?? envDefaultSiteSession() ?? cmd.siteSession ?? 'persistent';
456
478
  }
457
479
  function resolveAdapterBrowserSession(cmd, siteSession) {
458
480
  if (siteSession === 'persistent')
@@ -164,7 +164,12 @@ describe('executeCommand — non-browser timeout', () => {
164
164
  expect(closeWindow).not.toHaveBeenCalled();
165
165
  vi.restoreAllMocks();
166
166
  });
167
- it('keeps default browser commands on one-shot adapter sessions', async () => {
167
+ // [pp-only] PublishPort 把默认会话焊成 persistent(见 resolveSiteSession),所以
168
+ // 无 flag、无 env、适配器也没标 siteSession 时,浏览器命令默认复用同一个 site 会话、
169
+ // 保留标签、跨命令复用窗口 —— 而不是上游默认的一次性 ephemeral。
170
+ it('defaults browser commands to persistent site sessions (pp-only)', async () => {
171
+ const prev = process.env.PUBLISHPORT_SITE_SESSION;
172
+ delete process.env.PUBLISHPORT_SITE_SESSION; // 确保不受外部 env 干扰,测的是「纯默认」
168
173
  const closeWindow = vi.fn().mockResolvedValue(undefined);
169
174
  const mockPage = { closeWindow };
170
175
  const sessionOpts = [];
@@ -173,26 +178,66 @@ describe('executeCommand — non-browser timeout', () => {
173
178
  sessionOpts.push(opts ?? {});
174
179
  return fn(mockPage);
175
180
  });
176
- const cmd = cli({
177
- site: 'test-execution',
178
- name: 'site-session-default', access: 'read',
179
- description: 'test default one-shot browser session',
180
- browser: true,
181
- strategy: Strategy.PUBLIC,
182
- func: async () => [{ ok: true }],
181
+ try {
182
+ const cmd = cli({
183
+ site: 'test-execution',
184
+ name: 'site-session-default', access: 'read',
185
+ description: 'test default persistent browser session',
186
+ browser: true,
187
+ strategy: Strategy.PUBLIC,
188
+ func: async () => [{ ok: true }],
189
+ });
190
+ await executeCommand(cmd, {});
191
+ await executeCommand(cmd, {});
192
+ expect(sessionOpts).toHaveLength(2);
193
+ // 两条命令落到同一个稳定 site 会话(无 UUID)→ 复用同一标签/窗口。
194
+ expect(sessionOpts[0]).toMatchObject({ session: 'site:test-execution', windowMode: 'background', siteSession: 'persistent' });
195
+ expect(sessionOpts[1]).toMatchObject({ session: 'site:test-execution', windowMode: 'background', siteSession: 'persistent' });
196
+ // persistent → keepTab=true → 命令跑完不关窗。
197
+ expect(closeWindow).not.toHaveBeenCalled();
198
+ }
199
+ finally {
200
+ if (prev === undefined)
201
+ delete process.env.PUBLISHPORT_SITE_SESSION;
202
+ else
203
+ process.env.PUBLISHPORT_SITE_SESSION = prev;
204
+ vi.restoreAllMocks();
205
+ }
206
+ });
207
+ // [pp-only] 反向开关:PUBLISHPORT_SITE_SESSION=ephemeral 时退回一次性会话。
208
+ it('env PUBLISHPORT_SITE_SESSION=ephemeral forces one-shot sessions back', async () => {
209
+ const prev = process.env.PUBLISHPORT_SITE_SESSION;
210
+ process.env.PUBLISHPORT_SITE_SESSION = 'ephemeral';
211
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
212
+ const mockPage = { closeWindow };
213
+ const sessionOpts = [];
214
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
215
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
216
+ sessionOpts.push(opts ?? {});
217
+ return fn(mockPage);
183
218
  });
184
- await executeCommand(cmd, {});
185
- await executeCommand(cmd, {});
186
- expect(sessionOpts).toHaveLength(2);
187
- expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/);
188
- expect(sessionOpts[1]?.session).toMatch(/^site:test-execution:/);
189
- expect(sessionOpts[0]?.session).not.toBe(sessionOpts[1]?.session);
190
- expect(sessionOpts[0]?.idleTimeout).toBeUndefined();
191
- expect(sessionOpts[1]?.idleTimeout).toBeUndefined();
192
- expect(sessionOpts[0]?.windowMode).toBe('background');
193
- expect(sessionOpts[1]?.windowMode).toBe('background');
194
- expect(closeWindow).toHaveBeenCalledTimes(2);
195
- vi.restoreAllMocks();
219
+ try {
220
+ const cmd = cli({
221
+ site: 'test-execution',
222
+ name: 'site-session-force-ephemeral', access: 'read',
223
+ description: 'test env forces ephemeral',
224
+ browser: true,
225
+ strategy: Strategy.PUBLIC,
226
+ func: async () => [{ ok: true }],
227
+ });
228
+ await executeCommand(cmd, {});
229
+ await executeCommand(cmd, {});
230
+ expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/);
231
+ expect(sessionOpts[0]?.session).not.toBe(sessionOpts[1]?.session);
232
+ expect(closeWindow).toHaveBeenCalledTimes(2);
233
+ }
234
+ finally {
235
+ if (prev === undefined)
236
+ delete process.env.PUBLISHPORT_SITE_SESSION;
237
+ else
238
+ process.env.PUBLISHPORT_SITE_SESSION = prev;
239
+ vi.restoreAllMocks();
240
+ }
196
241
  });
197
242
  it('lets user --site-session ephemeral override adapter persistent metadata', async () => {
198
243
  const closeWindow = vi.fn().mockResolvedValue(undefined);
@@ -223,6 +268,78 @@ describe('executeCommand — non-browser timeout', () => {
223
268
  vi.restoreAllMocks();
224
269
  }
225
270
  });
271
+ // [pp-only] PUBLISHPORT_SITE_SESSION 环境变量把默认会话抬成 persistent,且刻意
272
+ // 压过 cmd.siteSession,好让 auth quickCheck 那种把 ephemeral 焊死的命令也复用窗口。
273
+ it('env PUBLISHPORT_SITE_SESSION=persistent lifts an ephemeral-baked command to persistent', async () => {
274
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
275
+ const mockPage = { closeWindow };
276
+ const sessionOpts = [];
277
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
278
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
279
+ sessionOpts.push(opts ?? {});
280
+ return fn(mockPage);
281
+ });
282
+ const prev = process.env.PUBLISHPORT_SITE_SESSION;
283
+ process.env.PUBLISHPORT_SITE_SESSION = 'persistent';
284
+ try {
285
+ const cmd = cli({
286
+ site: 'test-execution',
287
+ name: 'site-session-env-persistent', access: 'read',
288
+ description: 'test env-driven persistent session',
289
+ browser: true,
290
+ strategy: Strategy.PUBLIC,
291
+ siteSession: 'ephemeral',
292
+ func: async () => [{ ok: true }],
293
+ });
294
+ await executeCommand(cmd, {});
295
+ await executeCommand(cmd, {});
296
+ expect(sessionOpts).toHaveLength(2);
297
+ expect(sessionOpts[0]).toMatchObject({ session: 'site:test-execution', siteSession: 'persistent' });
298
+ expect(sessionOpts[1]).toMatchObject({ session: 'site:test-execution', siteSession: 'persistent' });
299
+ // persistent → keepTab=true → 窗口不释放,跨命令复用同一标签。
300
+ expect(closeWindow).not.toHaveBeenCalled();
301
+ }
302
+ finally {
303
+ if (prev === undefined)
304
+ delete process.env.PUBLISHPORT_SITE_SESSION;
305
+ else
306
+ process.env.PUBLISHPORT_SITE_SESSION = prev;
307
+ vi.restoreAllMocks();
308
+ }
309
+ });
310
+ it('explicit --site-session ephemeral still overrides the env persistent default', async () => {
311
+ const closeWindow = vi.fn().mockResolvedValue(undefined);
312
+ const mockPage = { closeWindow };
313
+ const sessionOpts = [];
314
+ vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
315
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
316
+ sessionOpts.push(opts ?? {});
317
+ return fn(mockPage);
318
+ });
319
+ const prev = process.env.PUBLISHPORT_SITE_SESSION;
320
+ process.env.PUBLISHPORT_SITE_SESSION = 'persistent';
321
+ try {
322
+ const cmd = cli({
323
+ site: 'test-execution',
324
+ name: 'site-session-env-flag-override', access: 'read',
325
+ description: 'test explicit flag beats env',
326
+ browser: true,
327
+ strategy: Strategy.PUBLIC,
328
+ func: async () => [{ ok: true }],
329
+ });
330
+ await executeCommand(cmd, {}, false, { siteSession: 'ephemeral' });
331
+ expect(sessionOpts).toHaveLength(1);
332
+ expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/);
333
+ expect(closeWindow).toHaveBeenCalledTimes(1);
334
+ }
335
+ finally {
336
+ if (prev === undefined)
337
+ delete process.env.PUBLISHPORT_SITE_SESSION;
338
+ else
339
+ process.env.PUBLISHPORT_SITE_SESSION = prev;
340
+ vi.restoreAllMocks();
341
+ }
342
+ });
226
343
  it('skips repeated domain pre-navigation for persistent site sessions', async () => {
227
344
  const closeWindow = vi.fn().mockResolvedValue(undefined);
228
345
  const goto = vi.fn().mockResolvedValue(undefined);
@@ -366,7 +483,9 @@ describe('executeCommand — non-browser timeout', () => {
366
483
  strategy: Strategy.PUBLIC,
367
484
  func: async () => { throw new Error('adapter failure'); },
368
485
  });
369
- await expect(executeCommand(cmd, {})).rejects.toThrow('adapter failure');
486
+ // [pp-only] 默认已是 persistent(不关窗);本例专测「失败路径会释放标签」,故显式
487
+ // 走 ephemeral 才有 closeWindow 可断言。
488
+ await expect(executeCommand(cmd, {}, false, { siteSession: 'ephemeral' })).rejects.toThrow('adapter failure');
370
489
  expect(closeWindow).toHaveBeenCalledTimes(1);
371
490
  vi.restoreAllMocks();
372
491
  });
@@ -512,7 +631,8 @@ describe('executeCommand — non-browser timeout', () => {
512
631
  strategy: Strategy.PUBLIC,
513
632
  func: async () => { throw new Error('adapter failure'); },
514
633
  });
515
- const thrown = await executeCommand(cmd, {}, false, { trace: 'retain-on-failure' }).catch((err) => err);
634
+ // [pp-only] 默认 persistent 不关窗;本例断言失败路径 closeWindow,故显式 ephemeral。
635
+ const thrown = await executeCommand(cmd, {}, false, { trace: 'retain-on-failure', siteSession: 'ephemeral' }).catch((err) => err);
516
636
  expect(thrown).toBeInstanceOf(Error);
517
637
  expect(thrown.message).toContain('adapter failure');
518
638
  const tracesRoot = path.join(baseDir, 'profiles', 'default', 'traces');
@@ -572,7 +692,8 @@ describe('executeCommand — non-browser timeout', () => {
572
692
  strategy: Strategy.PUBLIC,
573
693
  func: async () => [{ ok: true }],
574
694
  });
575
- await expect(executeCommand(cmd, {}, false, { trace: 'on', onTraceExport })).resolves.toEqual([{ ok: true }]);
695
+ // [pp-only] 默认 persistent 不关窗;本例断言成功路径 closeWindow,故显式 ephemeral。
696
+ await expect(executeCommand(cmd, {}, false, { trace: 'on', onTraceExport, siteSession: 'ephemeral' })).resolves.toEqual([{ ok: true }]);
576
697
  const stderr = stderrSpy.mock.calls.flat().join('\n');
577
698
  expect(stderr).toContain('OpenCLI trace artifact:');
578
699
  const tracesRoot = path.join(baseDir, 'profiles', 'default', 'traces');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "publishport-opencli",
3
- "version": "1.8.5-pp.6",
3
+ "version": "1.8.5-pp.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },