zames_pro 2.72.0 → 2.72.2

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.72.2] - 2026-10-09
11
+
12
+ ### Added
13
+
14
+ - **self-smoke:** add --reuse-profile and a bounded, explicit login failure (exit 2)
15
+
16
+ ### Added
17
+
18
+ - **self-smoke:** add --reuse-profile and a bounded, explicit login failure (exit 2)
19
+
20
+ ## [2.72.1] - 2026-10-09
21
+
22
+ ### Changed
23
+
24
+ - **browser:** move cleanAnswer into browser-pure.ts (C3)
25
+ - **browser:** extract the element-level DOM callbacks into browser-dom.ts (C3)
26
+ - **browser:** extract the headless UA reader callback (C3)
27
+ - **browser:** extract the login-submit, UA-scan and busy-title callbacks (C3)
28
+ - **browser:** extract the in-page DOM callbacks into browser-dom.ts (C3)
29
+ - **answer-parse:** import the parser directly, drop the agent-loop re-export (C3b)
30
+
31
+ ### Changed
32
+
33
+ - **browser:** move cleanAnswer into browser-pure.ts (C3)
34
+ - **browser:** extract the element-level DOM callbacks into browser-dom.ts (C3)
35
+ - **browser:** extract the headless UA reader callback (C3)
36
+ - **browser:** extract the login-submit, UA-scan and busy-title callbacks (C3)
37
+ - **browser:** extract the in-page DOM callbacks into browser-dom.ts (C3)
38
+ - **answer-parse:** import the parser directly, drop the agent-loop re-export (C3b)
39
+
10
40
  ## [2.72.0] - 2026-10-08
11
41
 
12
42
  ### Added
@@ -1090,9 +1090,7 @@ ${diag}`;
1090
1090
  }
1091
1091
  return 'Iteration limit reached.';
1092
1092
  }
1093
- // The pure helpers (tool-call parser + answer heuristics) moved to
1094
- // answer-parse.ts (BACKLOG C3). They are imported for runAgentLoop and
1095
- // RE-EXPORTED, so the public API of this module is unchanged (tests import
1096
- // parseToolCall / responseLooksLikeToolCall / truncateToolResult from here).
1093
+ // The pure helpers (tool-call parser + answer heuristics) live in
1094
+ // answer-parse.ts (BACKLOG C3) and are imported here for runAgentLoop. They are
1095
+ // NOT re-exported: importers get them from answer-parse.js directly (C3b).
1097
1096
  import { parseToolCall, responseLooksLikeToolCall, truncateToolResult, normForStale, looksLikeUnfinishedWork, isMeaningfulRespond, extractPreToolText, } from './answer-parse.js';
1098
- export { parseToolCall, responseLooksLikeToolCall, truncateToolResult };
@@ -0,0 +1,416 @@
1
+ // Pure in-page DOM helpers extracted from DeepSeekBrowser (BACKLOG C3).
2
+ //
3
+ // Every function here runs INSIDE page.evaluate, so it MUST be fully
4
+ // self-contained: Playwright serializes the function SOURCE and evaluates it in
5
+ // the page, where module scope does not exist. Only DOM globals and the passed
6
+ // argument are available — a reference to another helper of this file would be
7
+ // lost. This is the same constraint browser-chats.ts works under; keeping the
8
+ // callbacks here instead of inline in the class keeps the class body thin
9
+ // without changing behavior.
10
+ /**
11
+ * Read ONLY visible toast/notification containers as one text blob.
12
+ *
13
+ * WHY this list is narrow: a broad `[class*="error" i]` matched leftover page
14
+ * text and produced a FALSE rate limit. Keeping to real toast containers means
15
+ * no false positives, so ask() does not go into a needless 5-minute wait.
16
+ */
17
+ export function readPageToastsInDom() {
18
+ const sels = [
19
+ '[role="alert"]',
20
+ '[class*="toast" i]',
21
+ '[class*="notification" i]',
22
+ ];
23
+ let out = '';
24
+ for (const s of sels) {
25
+ for (const el of Array.from(document.querySelectorAll(s))) {
26
+ const e = el;
27
+ const st = getComputedStyle(e);
28
+ if (st.display === 'none' || st.visibility === 'hidden')
29
+ continue;
30
+ out += ' ' + (e.innerText || '');
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * Read a visible login/credentials error message from the page, if any.
37
+ * Best-effort: the caller slices/trims; this returns the joined text.
38
+ */
39
+ export function readLoginErrorInDom() {
40
+ const sels = [
41
+ '[role="alert"]',
42
+ '[class*="error" i]',
43
+ '[class*="toast" i]',
44
+ '[class*="notification" i]',
45
+ ];
46
+ let out = '';
47
+ for (const s of sels) {
48
+ for (const el of Array.from(document.querySelectorAll(s))) {
49
+ const e = el;
50
+ const st = getComputedStyle(e);
51
+ if (st.display === 'none' || st.visibility === 'hidden')
52
+ continue;
53
+ const t = (e.innerText || '').trim();
54
+ if (t)
55
+ out += ' ' + t;
56
+ }
57
+ }
58
+ return out.replace(/\s+/g, ' ').trim();
59
+ }
60
+ /**
61
+ * The answer as RENDERED on the page, ALWAYS from the DOM (never the network
62
+ * capture). Reasoning blocks (think class) are skipped and must never be
63
+ * picked up as the answer.
64
+ */
65
+ export function readLastAnswerInDom(opts) {
66
+ const sels = opts.sels;
67
+ const think = new RegExp(opts.thinkRe, 'i');
68
+ const inThink = (e) => {
69
+ let n = e;
70
+ while (n) {
71
+ const cls = (n.className || '').toString();
72
+ if (think.test(cls))
73
+ return true;
74
+ n = n.parentElement;
75
+ }
76
+ return false;
77
+ };
78
+ let el = null;
79
+ for (const s of sels) {
80
+ const list = document.querySelectorAll(s);
81
+ if (!list.length)
82
+ continue;
83
+ for (let i = list.length - 1; i >= 0; i--) {
84
+ const cand = list[i];
85
+ if (inThink(cand))
86
+ continue;
87
+ el = cand;
88
+ break;
89
+ }
90
+ if (el)
91
+ break;
92
+ }
93
+ if (!el)
94
+ return '';
95
+ const out = el.innerText || el.textContent || '';
96
+ const NL = String.fromCharCode(10);
97
+ return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
98
+ }
99
+ /**
100
+ * A CHEAP growth signal for the "did the answer start?" loop: the number of
101
+ * message/answer nodes plus the length of the LAST answer element. The old code
102
+ * read document.body.innerText.length every tick, which serializes the WHOLE
103
+ * page (sidebar, history, menus) and forces a layout — expensive on a long chat.
104
+ */
105
+ export function chatSignalInDom(opts) {
106
+ const nodes = document.querySelectorAll(opts.nodesSel).length;
107
+ let lastLen = 0;
108
+ const answers = document.querySelectorAll(opts.mdSel);
109
+ if (answers.length) {
110
+ const lastEl = answers[answers.length - 1];
111
+ lastLen = (lastEl.innerText || '').length;
112
+ }
113
+ return { nodes, lastLen };
114
+ }
115
+ /**
116
+ * Cheap first probe for the Continue button: ONE scan of buttons/role-buttons
117
+ * for a matching visible label. The common path (no button) is probed every
118
+ * tick, so a single evaluate replaces the getByRole count+isVisible scan.
119
+ */
120
+ export function continueVisibleCheapInDom(nameRe) {
121
+ const re = new RegExp(nameRe, 'i');
122
+ const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
123
+ for (const e of cands) {
124
+ const label = ((e.textContent || '') +
125
+ ' ' +
126
+ (e.getAttribute('aria-label') || ''))
127
+ .replace(/\s+/g, ' ')
128
+ .trim();
129
+ if (!re.test(label))
130
+ continue;
131
+ const st = getComputedStyle(e);
132
+ if (st.display === 'none' || st.visibility === 'hidden')
133
+ continue;
134
+ const r = e.getBoundingClientRect();
135
+ if (r.width > 0 && r.height > 0)
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+ /**
141
+ * Find the Stop button in the DeepSeek UI. We can't rely on the class alone:
142
+ * during generation the send button (the same circle button) changes its icon
143
+ * to a "square" (stop) while keeping the classes, so a square icon (svg rect)
144
+ * also means Stop.
145
+ */
146
+ export function stopButtonVisibleInDom(btnRe) {
147
+ const re = new RegExp(btnRe, 'i');
148
+ const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
149
+ for (const b of btns) {
150
+ const cls = (b.className || '').toString();
151
+ if (!re.test(cls))
152
+ continue;
153
+ const label = ((b.getAttribute('aria-label') || '') +
154
+ ' ' +
155
+ (b.getAttribute('title') || '') +
156
+ ' ' +
157
+ (b.textContent || '')).toLowerCase();
158
+ if (/stop|останов/.test(label))
159
+ return true;
160
+ // A square icon = Stop button; an arrow (path without rect) = send.
161
+ const svg = b.querySelector('svg');
162
+ if (svg && svg.querySelector('rect'))
163
+ return true;
164
+ }
165
+ return false;
166
+ }
167
+ /**
168
+ * Raw-DOM Stop click with the full pointer/mouse sequence (older builds
169
+ * without a proper role, or a click intercepted by an overlay). Returns true
170
+ * when a square-icon button was found and clicked.
171
+ */
172
+ export function clickStopInDom(btnRe) {
173
+ const re = new RegExp(btnRe, 'i');
174
+ const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
175
+ for (const b of btns) {
176
+ const cls = (b.className || '').toString();
177
+ if (!re.test(cls))
178
+ continue;
179
+ const svg = b.querySelector('svg');
180
+ if (svg && svg.querySelector('rect')) {
181
+ for (const type of [
182
+ 'pointerdown',
183
+ 'mousedown',
184
+ 'pointerup',
185
+ 'mouseup',
186
+ 'click',
187
+ ]) {
188
+ b.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
189
+ }
190
+ b.click();
191
+ return true;
192
+ }
193
+ }
194
+ return false;
195
+ }
196
+ /**
197
+ * Raw-DOM scan for a visible Continue button (fallback for builds without a
198
+ * proper role). The label match is an EXACT short form (optionally with a
199
+ * reasoning suffix) so a "Continue" inside rendered prose is never matched.
200
+ */
201
+ export function continueVisibleInDom() {
202
+ const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
203
+ .replace(/\s+/g, ' ')
204
+ .trim();
205
+ const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
206
+ const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
207
+ for (const e of cands) {
208
+ if (!isExact(labelOf(e)))
209
+ continue;
210
+ const st = getComputedStyle(e);
211
+ if (st.display === 'none' || st.visibility === 'hidden')
212
+ continue;
213
+ const r = e.getBoundingClientRect();
214
+ if (r.width > 0 && r.height > 0)
215
+ return true;
216
+ }
217
+ return false;
218
+ }
219
+ /**
220
+ * Raw-DOM Continue click with the full pointer/mouse sequence (fallback for a
221
+ * build without a proper role, or a click intercepted by an overlay).
222
+ * Returns true when a matching visible button was clicked.
223
+ */
224
+ export function clickContinueInDom() {
225
+ const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
226
+ .replace(/\s+/g, ' ')
227
+ .trim();
228
+ const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
229
+ const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
230
+ for (const e of cands) {
231
+ const t = labelOf(e);
232
+ if (!isExact(t))
233
+ continue;
234
+ const st = getComputedStyle(e);
235
+ if (st.display === 'none' || st.visibility === 'hidden')
236
+ continue;
237
+ const rect = e.getBoundingClientRect();
238
+ if (rect.width === 0 || rect.height === 0)
239
+ continue;
240
+ const cx = rect.left + rect.width / 2;
241
+ const cy = rect.top + rect.height / 2;
242
+ const opts = {
243
+ bubbles: true,
244
+ cancelable: true,
245
+ composed: true,
246
+ clientX: cx,
247
+ clientY: cy,
248
+ button: 0,
249
+ };
250
+ try {
251
+ e.dispatchEvent(new PointerEvent('pointerdown', opts));
252
+ e.dispatchEvent(new MouseEvent('mousedown', opts));
253
+ e.dispatchEvent(new PointerEvent('pointerup', opts));
254
+ e.dispatchEvent(new MouseEvent('mouseup', opts));
255
+ }
256
+ catch { }
257
+ e.click();
258
+ return true;
259
+ }
260
+ return false;
261
+ }
262
+ /**
263
+ * Insert text into a contenteditable/input element by dispatching a synthetic
264
+ * paste event (the way a real paste lands, which React handlers honor). Runs
265
+ * bound to the element, so it receives `el` + the text value.
266
+ */
267
+ export function pasteTextIntoElement(el, value) {
268
+ el.focus();
269
+ const dt = new DataTransfer();
270
+ dt.setData('text/plain', value);
271
+ const ev = new ClipboardEvent('paste', {
272
+ clipboardData: dt,
273
+ bubbles: true,
274
+ cancelable: true,
275
+ });
276
+ el.dispatchEvent(ev);
277
+ return true;
278
+ }
279
+ /**
280
+ * Read the current text of an input/textarea (its `value`) or a
281
+ * contenteditable element (its innerText/textContent). Runs bound to the
282
+ * element so it can distinguish the two by tagName.
283
+ */
284
+ export function readElementText(el) {
285
+ const tag = el.tagName.toLowerCase();
286
+ if (tag === 'textarea' || tag === 'input') {
287
+ return el.value;
288
+ }
289
+ return el.innerText || el.textContent || '';
290
+ }
291
+ /**
292
+ * Lowercased tagName of an element. Runs bound to the element (Playwright's
293
+ * Locator.evaluate), used to tell a native input/textarea from a
294
+ * contenteditable box.
295
+ */
296
+ export function elementTagNameInDom(el) {
297
+ return el.tagName.toLowerCase();
298
+ }
299
+ /**
300
+ * Is the send control actually enabled? DeepSeek's send control is a
301
+ * div[role=button]; Playwright's isEnabled() does NOT honor aria-disabled on a
302
+ * div, and a click on a disabled button is silently ignored (during an upload
303
+ * the message stays in the box). Read the attribute and a disabled class
304
+ * directly. Runs bound to the element.
305
+ */
306
+ export function isElementEnabledInDom(el) {
307
+ if (el.getAttribute('aria-disabled') === 'true')
308
+ return false;
309
+ const cls = (el.className || '').toString();
310
+ if (/disabled|is-disabled/.test(cls))
311
+ return false;
312
+ return true;
313
+ }
314
+ /**
315
+ * The current text of the input box, trimmed — used to verify the message was
316
+ * actually SENT (DeepSeek clears the box on accept; if the text is still there
317
+ * the click/Enter was ignored). Runs bound to the element.
318
+ */
319
+ export function readInputTrimmedTextInDom(el) {
320
+ return (el.value || el.innerText || el.textContent || '').trim();
321
+ }
322
+ /**
323
+ * The real User-Agent the engine reports. Read after launch so a headless run
324
+ * can strip the "Headless" marker (DeepSeek's CDN 403s that UA).
325
+ */
326
+ export function readUserAgentInDom() {
327
+ return navigator.userAgent;
328
+ }
329
+ /**
330
+ * Find the index of the text/email/tel input that immediately precedes the
331
+ * password field, so the login form can be filled when no explicit
332
+ * LOGIN_SELECTORS matched. Returns -1 when not found.
333
+ */
334
+ export function findLoginInputIndexInDom(pwdSel) {
335
+ const p = document.querySelector(pwdSel);
336
+ if (!p)
337
+ return -1;
338
+ const inputs = Array.from(document.querySelectorAll('input'));
339
+ const pi = inputs.indexOf(p);
340
+ for (let i = pi - 1; i >= 0; i--) {
341
+ const t = (inputs[i].type || 'text').toLowerCase();
342
+ if (t === 'text' || t === 'email' || t === 'tel')
343
+ return i;
344
+ }
345
+ return -1;
346
+ }
347
+ /**
348
+ * Submit the login form from inside the page: click the button whose label
349
+ * matches the login regex, else the primary-looking button in the password
350
+ * field's form. Returns true when a button was clicked. The in-page click is
351
+ * a fallback for a React form whose submit control the selectors missed.
352
+ */
353
+ export function submitLoginFormInDom(opts) {
354
+ const loginRe = new RegExp(opts.submitRe, 'i');
355
+ const p = document.querySelector(opts.pwdSel);
356
+ if (!p)
357
+ return false;
358
+ const form = p.closest('form');
359
+ const scope = form || document;
360
+ const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
361
+ for (const b of btns) {
362
+ const txt = (b.textContent || '').trim().toLowerCase();
363
+ const aria = (b.getAttribute('aria-label') || '').toLowerCase();
364
+ if (loginRe.test(txt + ' ' + aria)) {
365
+ b.click();
366
+ return true;
367
+ }
368
+ }
369
+ // Fallback: the primary-looking button in the scope.
370
+ const primary = btns.find((b) => {
371
+ const cls = (b.className || '').toString();
372
+ return /primary|submit|ds-button--primary/i.test(cls);
373
+ });
374
+ if (primary) {
375
+ primary.click();
376
+ return true;
377
+ }
378
+ return false;
379
+ }
380
+ /**
381
+ * Prefix/restore the tab title so the operator, looking at the browser
382
+ * window, can tell that the agent is generating. The ORIGINAL title is saved
383
+ * ONCE on `window` and restored when generation ends, so DeepSeek's own chat
384
+ * title is never lost.
385
+ */
386
+ export function setBusyTitleInDom(on) {
387
+ const w = window;
388
+ if (on) {
389
+ if (typeof w.__zamesTitle !== 'string') {
390
+ w.__zamesTitle = document.title.replace(/^⏳\s*/, '');
391
+ }
392
+ document.title = '⏳ ' + w.__zamesTitle;
393
+ }
394
+ else if (typeof w.__zamesTitle === 'string') {
395
+ document.title = w.__zamesTitle;
396
+ w.__zamesTitle = undefined;
397
+ }
398
+ }
399
+ /**
400
+ * Count how many elements each candidate selector matches, for /debug-dom.
401
+ * Runs in-page (see the module header) so it takes the selector lists as an
402
+ * argument instead of referencing deepseek-ui.ts.
403
+ */
404
+ export function dumpDomCountsInDom(sels) {
405
+ const result = { answers: {}, stops: {}, inputs: {} };
406
+ for (const s of sels.answers) {
407
+ result.answers[s] = document.querySelectorAll(s).length;
408
+ }
409
+ for (const s of sels.stops) {
410
+ result.stops[s] = document.querySelectorAll(s).length;
411
+ }
412
+ for (const s of sels.inputs) {
413
+ result.inputs[s] = document.querySelectorAll(s).length;
414
+ }
415
+ return result;
416
+ }
@@ -2,7 +2,18 @@ import path from 'path';
2
2
  import os from 'os';
3
3
  import fs from 'fs/promises';
4
4
  import { chromium } from 'playwright';
5
- import { GENERATION_ERR_RE, INCOMPLETE_STATUS_RE, RATE_LIMIT_RE, SERVER_BUSY_RE, } from './deepseek-ui.js';
5
+ import { GENERATION_ERR_RE, INCOMPLETE_STATUS_RE, RATE_LIMIT_RE, SERVER_BUSY_RE, STATUS_RE, } from './deepseek-ui.js';
6
+ // Drop service placeholders ("Reading…", "Thinking…") so they are not mistaken
7
+ // for an answer; keep everything else as-is (including the whitespace the
8
+ // tool-call relies on). Pure: no `this`, no page.
9
+ export function cleanAnswer(raw) {
10
+ const t = (raw || '').trim();
11
+ if (!t)
12
+ return '';
13
+ if (STATUS_RE.test(t))
14
+ return '';
15
+ return raw;
16
+ }
6
17
  // Pure, state-free helpers extracted from browser.ts (BACKLOG C3). Nothing
7
18
  // here touches the DeepSeekBrowser instance state, so this module is safe to
8
19
  // import anywhere (including hot-reloaded modules) without pulling in the
package/dist/browser.js CHANGED
@@ -2,8 +2,9 @@ import { chromium, } from 'playwright';
2
2
  import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
3
3
  import { rewriteFailedAttachments } from './commands.js';
4
4
  import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
5
- import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, STATUS_RE, ANSWER_URL_RE, } from './deepseek-ui.js';
5
+ import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, ANSWER_URL_RE, } from './deepseek-ui.js';
6
6
  import { probeUiHealth, } from './ui-health.js';
7
+ import { readPageToastsInDom, readLoginErrorInDom, readLastAnswerInDom, chatSignalInDom, continueVisibleCheapInDom, stopButtonVisibleInDom, clickStopInDom, continueVisibleInDom, clickContinueInDom, readUserAgentInDom, findLoginInputIndexInDom, submitLoginFormInDom, setBusyTitleInDom, pasteTextIntoElement, readElementText, elementTagNameInDom, isElementEnabledInDom, readInputTrimmedTextInDom, dumpDomCountsInDom, } from './browser-dom.js';
7
8
  import path from 'path';
8
9
  import os from 'os';
9
10
  import fs from 'fs/promises';
@@ -18,7 +19,7 @@ export { STOP_NAME_RE, CONTINUE_NAME_RE };
18
19
  // imported for the class below and RE-EXPORTED here, so the public API of this
19
20
  // module is unchanged (tests and other modules import these names from
20
21
  // './browser.js').
21
- import { askPassword, USER_DATA_DIR, parseHeadlessUACache, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, headlessUACacheReady, sanitizeHeadlessUA, AUTH_MARKER_FILE, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, cleanSingletonFiles, profileLooksLocked, nukeProfile, } from './browser-pure.js';
22
+ import { askPassword, USER_DATA_DIR, parseHeadlessUACache, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, headlessUACacheReady, sanitizeHeadlessUA, AUTH_MARKER_FILE, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, cleanAnswer, RateLimitError, ServerBusyError, GenerationIncompleteError, cleanSingletonFiles, profileLooksLocked, nukeProfile, } from './browser-pure.js';
22
23
  export { askPassword, parseHeadlessUACache, headlessUACacheReady, sanitizeHeadlessUA, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, };
23
24
  export class DeepSeekBrowser {
24
25
  headless;
@@ -257,19 +258,7 @@ export class DeepSeekBrowser {
257
258
  // the send loop.
258
259
  async _setBusyTitle(busy) {
259
260
  try {
260
- await this.page.evaluate((on) => {
261
- const w = window;
262
- if (on) {
263
- if (typeof w.__zamesTitle !== 'string') {
264
- w.__zamesTitle = document.title.replace(/^⏳\s*/, '');
265
- }
266
- document.title = '⏳ ' + w.__zamesTitle;
267
- }
268
- else if (typeof w.__zamesTitle === 'string') {
269
- document.title = w.__zamesTitle;
270
- w.__zamesTitle = undefined;
271
- }
272
- }, busy);
261
+ await this.page.evaluate(setBusyTitleInDom, busy);
273
262
  }
274
263
  catch { }
275
264
  }
@@ -308,7 +297,7 @@ export class DeepSeekBrowser {
308
297
  if (!this.headless)
309
298
  return;
310
299
  try {
311
- const ua = await this.page.evaluate(() => navigator.userAgent);
300
+ const ua = await this.page.evaluate(readUserAgentInDom);
312
301
  const fixed = sanitizeHeadlessUA(ua);
313
302
  if (fixed === ua)
314
303
  return;
@@ -641,27 +630,7 @@ export class DeepSeekBrowser {
641
630
  */
642
631
  async _readLoginError() {
643
632
  try {
644
- const txt = await this.page.evaluate(() => {
645
- const sels = [
646
- '[role="alert"]',
647
- '[class*="error" i]',
648
- '[class*="toast" i]',
649
- '[class*="notification" i]',
650
- ];
651
- let out = '';
652
- for (const s of sels) {
653
- for (const el of Array.from(document.querySelectorAll(s))) {
654
- const e = el;
655
- const st = getComputedStyle(e);
656
- if (st.display === 'none' || st.visibility === 'hidden')
657
- continue;
658
- const t = (e.innerText || '').trim();
659
- if (t)
660
- out += ' ' + t;
661
- }
662
- }
663
- return out.replace(/\s+/g, ' ').trim();
664
- });
633
+ const txt = await this.page.evaluate(readLoginErrorInDom);
665
634
  return txt.slice(0, 200);
666
635
  }
667
636
  catch {
@@ -690,19 +659,7 @@ export class DeepSeekBrowser {
690
659
  if (!userInput) {
691
660
  // Find the index of the password input and use the closest preceding
692
661
  // text/email/tel input as the login field.
693
- const idx = await this.page.evaluate((pwdSel) => {
694
- const p = document.querySelector(pwdSel);
695
- if (!p)
696
- return -1;
697
- const inputs = Array.from(document.querySelectorAll('input'));
698
- const pi = inputs.indexOf(p);
699
- for (let i = pi - 1; i >= 0; i--) {
700
- const t = (inputs[i].type || 'text').toLowerCase();
701
- if (t === 'text' || t === 'email' || t === 'tel')
702
- return i;
703
- }
704
- return -1;
705
- }, PASSWORD_SELECTORS[0]);
662
+ const idx = await this.page.evaluate(findLoginInputIndexInDom, PASSWORD_SELECTORS[0]);
706
663
  if (idx >= 0)
707
664
  userInput = this.page.locator('input').nth(idx);
708
665
  }
@@ -756,33 +713,7 @@ export class DeepSeekBrowser {
756
713
  }
757
714
  // Button inside the same form as the password field.
758
715
  const formClicked = await this.page
759
- .evaluate((opts) => {
760
- const loginRe = new RegExp(opts.submitRe, 'i');
761
- const p = document.querySelector(opts.pwdSel);
762
- if (!p)
763
- return false;
764
- const form = p.closest('form');
765
- const scope = form || document;
766
- const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
767
- for (const b of btns) {
768
- const txt = (b.textContent || '').trim().toLowerCase();
769
- const aria = (b.getAttribute('aria-label') || '').toLowerCase();
770
- if (loginRe.test(txt + ' ' + aria)) {
771
- b.click();
772
- return true;
773
- }
774
- }
775
- // Fallback: the primary-looking button in the scope.
776
- const primary = btns.find((b) => {
777
- const cls = (b.className || '').toString();
778
- return /primary|submit|ds-button--primary/i.test(cls);
779
- });
780
- if (primary) {
781
- primary.click();
782
- return true;
783
- }
784
- return false;
785
- }, {
716
+ .evaluate(submitLoginFormInDom, {
786
717
  pwdSel: PASSWORD_SELECTORS[0],
787
718
  submitRe: LOGIN_FORM_SUBMIT_RE.source,
788
719
  })
@@ -936,44 +867,10 @@ export class DeepSeekBrowser {
936
867
  // call". The DOM reader has no such self-comparison problem: before the
937
868
  // send the DOM shows the OLD answer, after it the NEW one.
938
869
  async _readLastAnswerTextDom() {
939
- return await this.page.evaluate((opts) => {
940
- const sels = opts.sels;
941
- // DeepSeek stores the model's reasoning in think blocks (THINK_CLASS_RE).
942
- // They are NOT the answer and must never be picked up as the answer
943
- // (otherwise the terminal would show the long reasoning). We also
944
- // prefer the first (most specific) selector with a hit.
945
- const think = new RegExp(opts.thinkRe, 'i');
946
- const inThink = (e) => {
947
- let n = e;
948
- while (n) {
949
- const cls = (n.className || '').toString();
950
- if (think.test(cls))
951
- return true;
952
- n = n.parentElement;
953
- }
954
- return false;
955
- };
956
- let el = null;
957
- for (const s of sels) {
958
- const list = document.querySelectorAll(s);
959
- if (!list.length)
960
- continue;
961
- for (let i = list.length - 1; i >= 0; i--) {
962
- const cand = list[i];
963
- if (inThink(cand))
964
- continue;
965
- el = cand;
966
- break;
967
- }
968
- if (el)
969
- break;
970
- }
971
- if (!el)
972
- return '';
973
- const out = el.innerText || el.textContent || '';
974
- const NL = String.fromCharCode(10);
975
- return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
976
- }, { sels: ANSWER_SELECTORS, thinkRe: THINK_CLASS_RE.source });
870
+ return await this.page.evaluate(readLastAnswerInDom, {
871
+ sels: ANSWER_SELECTORS,
872
+ thinkRe: THINK_CLASS_RE.source,
873
+ });
977
874
  }
978
875
  // We read ONLY visible toasts/notifications, not the whole page text.
979
876
  // Otherwise we catch "try again later" from service/hidden blocks and go
@@ -982,26 +879,7 @@ export class DeepSeekBrowser {
982
879
  // text from the page and produced a FALSE rate limit (the agent stopped
983
880
  // while the chat had no error). Keep only real toast/alert containers.
984
881
  async _readPageText() {
985
- return await this.page
986
- .evaluate(() => {
987
- const sels = [
988
- '[role="alert"]',
989
- '[class*="toast" i]',
990
- '[class*="notification" i]',
991
- ];
992
- let out = '';
993
- for (const s of sels) {
994
- for (const el of Array.from(document.querySelectorAll(s))) {
995
- const e = el;
996
- const st = getComputedStyle(e);
997
- if (st.display === 'none' || st.visibility === 'hidden')
998
- continue;
999
- out += ' ' + (e.innerText || '');
1000
- }
1001
- }
1002
- return out;
1003
- })
1004
- .catch(() => '');
882
+ return await this.page.evaluate(readPageToastsInDom).catch(() => '');
1005
883
  }
1006
884
  // The "clean" filter, but read from the DOM only. Used by _askOnce for the
1007
885
  // before/after comparison: the network capture must not be compared against
@@ -1010,16 +888,10 @@ export class DeepSeekBrowser {
1010
888
  const raw = await this._readLastAnswerTextDom().catch(() => '');
1011
889
  return this._cleanAnswer(raw);
1012
890
  }
1013
- // Drop service placeholders ("Reading…", "Thinking…") so they are not
1014
- // mistaken for an answer; keep everything else as-is (including whitespace
1015
- // the tool-call relies on).
891
+ // The pure filter lives in browser-pure.ts (cleanAnswer); kept as a method
892
+ // for the existing `this._cleanAnswer(...)` call sites.
1016
893
  _cleanAnswer(raw) {
1017
- const t = (raw || '').trim();
1018
- if (!t)
1019
- return '';
1020
- if (STATUS_RE.test(t))
1021
- return '';
1022
- return raw;
894
+ return cleanAnswer(raw);
1023
895
  }
1024
896
  // Find the Stop button in the DeepSeek UI. We can't rely on the class
1025
897
  // alone: during generation the send button (the same circle button)
@@ -1029,27 +901,7 @@ export class DeepSeekBrowser {
1029
901
  if (explicit)
1030
902
  return true;
1031
903
  return await this.page
1032
- .evaluate((btnRe) => {
1033
- const re = new RegExp(btnRe, 'i');
1034
- const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
1035
- for (const b of btns) {
1036
- const cls = (b.className || '').toString();
1037
- if (!re.test(cls))
1038
- continue;
1039
- const label = ((b.getAttribute('aria-label') || '') +
1040
- ' ' +
1041
- (b.getAttribute('title') || '') +
1042
- ' ' +
1043
- (b.textContent || '')).toLowerCase();
1044
- if (/stop|останов/.test(label))
1045
- return true;
1046
- // A square icon = Stop button; an arrow (path without rect) = send.
1047
- const svg = b.querySelector('svg');
1048
- if (svg && svg.querySelector('rect'))
1049
- return true;
1050
- }
1051
- return false;
1052
- }, BUTTON_CLASS_RE.source)
904
+ .evaluate(stopButtonVisibleInDom, BUTTON_CLASS_RE.source)
1053
905
  .catch(() => false);
1054
906
  }
1055
907
  async _isGenerating() {
@@ -1063,16 +915,10 @@ export class DeepSeekBrowser {
1063
915
  // all we need to tell "the page grew / a new answer is streaming".
1064
916
  async _chatSignal() {
1065
917
  return await this.page
1066
- .evaluate((opts) => {
1067
- const nodes = document.querySelectorAll(opts.nodesSel).length;
1068
- let lastLen = 0;
1069
- const answers = document.querySelectorAll(opts.mdSel);
1070
- if (answers.length) {
1071
- const lastEl = answers[answers.length - 1];
1072
- lastLen = (lastEl.innerText || '').length;
1073
- }
1074
- return { nodes, lastLen };
1075
- }, { nodesSel: CHAT_SIGNAL_NODES_SELECTOR, mdSel: MARKDOWN_SELECTOR })
918
+ .evaluate(chatSignalInDom, {
919
+ nodesSel: CHAT_SIGNAL_NODES_SELECTOR,
920
+ mdSel: MARKDOWN_SELECTOR,
921
+ })
1076
922
  .catch(() => ({ nodes: 0, lastLen: 0 }));
1077
923
  }
1078
924
  // Cheap first probe for the Continue button. Runs ONE page.evaluate
@@ -1083,26 +929,7 @@ export class DeepSeekBrowser {
1083
929
  // HITS do we run the full scan to get a clickable Locator.
1084
930
  async _continueVisibleCheap() {
1085
931
  return await this.page
1086
- .evaluate((nameRe) => {
1087
- const re = new RegExp(nameRe, 'i');
1088
- const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1089
- for (const e of cands) {
1090
- const label = ((e.textContent || '') +
1091
- ' ' +
1092
- (e.getAttribute('aria-label') || ''))
1093
- .replace(/\s+/g, ' ')
1094
- .trim();
1095
- if (!re.test(label))
1096
- continue;
1097
- const st = getComputedStyle(e);
1098
- if (st.display === 'none' || st.visibility === 'hidden')
1099
- continue;
1100
- const r = e.getBoundingClientRect();
1101
- if (r.width > 0 && r.height > 0)
1102
- return true;
1103
- }
1104
- return false;
1105
- }, CONTINUE_NAME_RE.source)
932
+ .evaluate(continueVisibleCheapInDom, CONTINUE_NAME_RE.source)
1106
933
  .catch(() => false);
1107
934
  }
1108
935
  // Is DeepSeek's "Continue" button currently visible? Used to BLOCK the
@@ -1128,26 +955,7 @@ export class DeepSeekBrowser {
1128
955
  catch { }
1129
956
  // Fallback: the raw DOM scan (older/newer DeepSeek builds where the role
1130
957
  // is absent).
1131
- return this.page
1132
- .evaluate(() => {
1133
- const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
1134
- .replace(/\s+/g, ' ')
1135
- .trim();
1136
- const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
1137
- const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1138
- for (const e of cands) {
1139
- if (!isExact(labelOf(e)))
1140
- continue;
1141
- const st = getComputedStyle(e);
1142
- if (st.display === 'none' || st.visibility === 'hidden')
1143
- continue;
1144
- const r = e.getBoundingClientRect();
1145
- if (r.width > 0 && r.height > 0)
1146
- return true;
1147
- }
1148
- return false;
1149
- })
1150
- .catch(() => false);
958
+ return this.page.evaluate(continueVisibleInDom).catch(() => false);
1151
959
  }
1152
960
  // Click the DeepSeek "Continue" button when it is visible (reasoning/answer
1153
961
  // paused by the server). Returns true when a click happened.
@@ -1227,44 +1035,7 @@ export class DeepSeekBrowser {
1227
1035
  // 2) Fallback: raw DOM scan + full pointer/mouse event sequence (an older
1228
1036
  // build without a proper role, or a click intercepted by an overlay).
1229
1037
  try {
1230
- const clicked = await this.page.evaluate(() => {
1231
- const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
1232
- .replace(/\s+/g, ' ')
1233
- .trim();
1234
- const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
1235
- const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1236
- for (const e of cands) {
1237
- const t = labelOf(e);
1238
- if (!isExact(t))
1239
- continue;
1240
- const st = getComputedStyle(e);
1241
- if (st.display === 'none' || st.visibility === 'hidden')
1242
- continue;
1243
- const rect = e.getBoundingClientRect();
1244
- if (rect.width === 0 || rect.height === 0)
1245
- continue;
1246
- const cx = rect.left + rect.width / 2;
1247
- const cy = rect.top + rect.height / 2;
1248
- const opts = {
1249
- bubbles: true,
1250
- cancelable: true,
1251
- composed: true,
1252
- clientX: cx,
1253
- clientY: cy,
1254
- button: 0,
1255
- };
1256
- try {
1257
- e.dispatchEvent(new PointerEvent('pointerdown', opts));
1258
- e.dispatchEvent(new MouseEvent('mousedown', opts));
1259
- e.dispatchEvent(new PointerEvent('pointerup', opts));
1260
- e.dispatchEvent(new MouseEvent('mouseup', opts));
1261
- }
1262
- catch { }
1263
- e.click();
1264
- return true;
1265
- }
1266
- return false;
1267
- });
1038
+ const clicked = await this.page.evaluate(clickContinueInDom);
1268
1039
  if (clicked) {
1269
1040
  this._lastContinueAt = Date.now();
1270
1041
  this._notice(this._t('ds.continue_clicked'));
@@ -1385,30 +1156,7 @@ export class DeepSeekBrowser {
1385
1156
  // 3) Raw DOM scan + the full pointer/mouse sequence (older builds without
1386
1157
  // a proper role, or a click intercepted by an overlay).
1387
1158
  const clicked = await this.page
1388
- .evaluate((btnRe) => {
1389
- const re = new RegExp(btnRe, 'i');
1390
- const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
1391
- for (const b of btns) {
1392
- const cls = (b.className || '').toString();
1393
- if (!re.test(cls))
1394
- continue;
1395
- const svg = b.querySelector('svg');
1396
- if (svg && svg.querySelector('rect')) {
1397
- for (const type of [
1398
- 'pointerdown',
1399
- 'mousedown',
1400
- 'pointerup',
1401
- 'mouseup',
1402
- 'click',
1403
- ]) {
1404
- b.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
1405
- }
1406
- b.click();
1407
- return true;
1408
- }
1409
- }
1410
- return false;
1411
- }, BUTTON_CLASS_RE.source)
1159
+ .evaluate(clickStopInDom, BUTTON_CLASS_RE.source)
1412
1160
  .catch(() => false);
1413
1161
  if (clicked)
1414
1162
  return true;
@@ -1578,7 +1326,7 @@ export class DeepSeekBrowser {
1578
1326
  this._askCancelled = true;
1579
1327
  }
1580
1328
  async _setInputText(input, text) {
1581
- const tag = await input.evaluate((el) => el.tagName.toLowerCase());
1329
+ const tag = await input.evaluate(elementTagNameInDom);
1582
1330
  const isNative = tag === 'textarea' || tag === 'input';
1583
1331
  if (isNative) {
1584
1332
  try {
@@ -1590,28 +1338,11 @@ export class DeepSeekBrowser {
1590
1338
  await input.click();
1591
1339
  await this.page.keyboard.press('Control+A');
1592
1340
  await this.page.keyboard.press('Delete');
1593
- const ok = await input.evaluate((el, value) => {
1594
- el.focus();
1595
- const dt = new DataTransfer();
1596
- dt.setData('text/plain', value);
1597
- const ev = new ClipboardEvent('paste', {
1598
- clipboardData: dt,
1599
- bubbles: true,
1600
- cancelable: true,
1601
- });
1602
- el.dispatchEvent(ev);
1603
- return true;
1604
- }, text);
1341
+ const ok = await input.evaluate(pasteTextIntoElement, text);
1605
1342
  if (!ok) {
1606
1343
  await this.page.keyboard.insertText(text);
1607
1344
  }
1608
- const got = await input.evaluate((el) => {
1609
- if (el.tagName.toLowerCase() === 'textarea' ||
1610
- el.tagName.toLowerCase() === 'input') {
1611
- return el.value;
1612
- }
1613
- return el.innerText || el.textContent || '';
1614
- });
1345
+ const got = await input.evaluate(readElementText);
1615
1346
  const norm = (s) => (s || '')
1616
1347
  .replace(/\r\n/g, '\n')
1617
1348
  .replace(/\u00a0/g, ' ')
@@ -1625,12 +1356,7 @@ export class DeepSeekBrowser {
1625
1356
  await this.page.keyboard.press('Control+A');
1626
1357
  await this.page.keyboard.press('Delete');
1627
1358
  await this.page.keyboard.insertText(text);
1628
- const got2 = await input.evaluate((el) => {
1629
- if (el.tagName.toLowerCase() === 'textarea' ||
1630
- el.tagName.toLowerCase() === 'input')
1631
- return el.value;
1632
- return el.innerText || el.textContent || '';
1633
- });
1359
+ const got2 = await input.evaluate(readElementText);
1634
1360
  if (norm(got2) !== norm(text)) {
1635
1361
  throw new Error(this._t('ds.input_partial', {
1636
1362
  got: norm(got2).length,
@@ -1920,14 +1646,7 @@ export class DeepSeekBrowser {
1920
1646
  // attribute (and a disabled class) directly — during an upload the
1921
1647
  // button is aria-disabled=true and a click is silently ignored.
1922
1648
  const enabled = await btn
1923
- .evaluate((el) => {
1924
- if (el.getAttribute('aria-disabled') === 'true')
1925
- return false;
1926
- const cls = (el.className || '').toString();
1927
- if (/disabled|is-disabled/.test(cls))
1928
- return false;
1929
- return true;
1930
- })
1649
+ .evaluate(isElementEnabledInDom)
1931
1650
  .catch(() => true);
1932
1651
  if (!enabled)
1933
1652
  continue;
@@ -1953,7 +1672,7 @@ export class DeepSeekBrowser {
1953
1672
  try {
1954
1673
  await this.page.waitForTimeout(300);
1955
1674
  const stillThere = await input
1956
- .evaluate((el) => (el.value || el.innerText || el.textContent || '').trim())
1675
+ .evaluate(readInputTrimmedTextInDom)
1957
1676
  .catch(() => '');
1958
1677
  if (stillThere) {
1959
1678
  this._askDebug('SEND not cleared, pressing Enter again');
@@ -2333,19 +2052,7 @@ export class DeepSeekBrowser {
2333
2052
  throw new Error(this._t('ds.not_launched'));
2334
2053
  const html = await this.page.content();
2335
2054
  await fs.writeFile(filePath, html, 'utf-8');
2336
- const report = await this.page.evaluate((sels) => {
2337
- const result = { answers: {}, stops: {}, inputs: {} };
2338
- for (const s of sels.answers) {
2339
- result.answers[s] = document.querySelectorAll(s).length;
2340
- }
2341
- for (const s of sels.stops) {
2342
- result.stops[s] = document.querySelectorAll(s).length;
2343
- }
2344
- for (const s of sels.inputs) {
2345
- result.inputs[s] = document.querySelectorAll(s).length;
2346
- }
2347
- return result;
2348
- }, {
2055
+ const report = await this.page.evaluate(dumpDomCountsInDom, {
2349
2056
  answers: ANSWER_SELECTORS,
2350
2057
  stops: STOP_SELECTORS,
2351
2058
  inputs: INPUT_SELECTORS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.72.0",
3
+ "version": "2.72.2",
4
4
  "description": "Terminal coding agent that drives chat.deepseek.com through Playwright — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,18 +7,35 @@
7
7
  // config.json (so auto-login works) and symlinks the Playwright browser cache.
8
8
  // Nothing in the operator's profile is written.
9
9
  //
10
- // Usage: npm run self-smoke (headless)
10
+ // Usage: npm run self-smoke (headless, empty profile)
11
11
  // npm run self-smoke -- --headed
12
- // Exit code 0 = all scenarios passed.
12
+ // npm run self-smoke -- --reuse-profile (logged-in profile, see below)
13
+ // Exit code 0 = all scenarios passed; 2 = login failed (could not start).
14
+ //
15
+ // --reuse-profile: copy the REAL profile (~/.zames/profile) read-only into the
16
+ // throwaway HOME so the browser starts ALREADY SIGNED IN. Reason: DeepSeek's
17
+ // anti-bot increasingly blocks a COLD login on a fresh profile (captcha / rate
18
+ // limit), so a smoke that logs in from scratch fails for reasons unrelated to
19
+ // the agent. Copying (never opening the live profile) keeps isolation intact.
13
20
 
14
21
  import fs from 'fs'
15
22
  import path from 'path'
16
23
  import os from 'os'
17
24
 
25
+ // --- flags -----------------------------------------------------------------
18
26
  const HEADED = process.argv.includes('--headed')
27
+ const REUSE_PROFILE =
28
+ process.argv.includes('--reuse-profile') ||
29
+ process.env.ZAMES_SMOKE_REUSE_PROFILE === '1'
30
+ // Optional explicit source profile (default: the real ~/.zames/profile).
31
+ const profileArgIdx = process.argv.indexOf('--profile')
32
+ const REAL_HOME = os.homedir()
33
+ const PROFILE_SRC =
34
+ profileArgIdx >= 0 && process.argv[profileArgIdx + 1]
35
+ ? path.resolve(process.argv[profileArgIdx + 1])
36
+ : path.join(REAL_HOME, '.zames', 'profile')
19
37
 
20
38
  // --- isolate HOME before importing project modules -------------------------
21
- const REAL_HOME = os.homedir()
22
39
  if (!process.env.ZAMES_SMOKE_HOME) {
23
40
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zames-smoke-'))
24
41
  process.env.HOME = tmp
@@ -35,6 +52,16 @@ if (!process.env.ZAMES_SMOKE_HOME) {
35
52
  if (fs.existsSync(realPw)) {
36
53
  fs.symlinkSync(realPw, path.join(tmp, '.cache', 'ms-playwright'))
37
54
  }
55
+ // Reuse a logged-in profile: COPY it (the live one is never opened) and drop
56
+ // the Singleton* files — Chrome refuses to start on a profile whose lock is
57
+ // present, and the live browser may hold it. The cookies survive the copy
58
+ // because Playwright launches with --password-store=basic.
59
+ if (REUSE_PROFILE && fs.existsSync(PROFILE_SRC)) {
60
+ fs.cpSync(PROFILE_SRC, path.join(tmp, '.zames', 'profile'), {
61
+ recursive: true,
62
+ filter: (src) => !/^Singleton/.test(path.basename(src)),
63
+ })
64
+ }
38
65
  }
39
66
  const SMOKE_HOME = process.env.ZAMES_SMOKE_HOME
40
67
  const WORK = path.join(SMOKE_HOME, 'work')
@@ -54,6 +81,9 @@ interface SmokeResult {
54
81
  note?: string
55
82
  }
56
83
  const results: SmokeResult[] = []
84
+ // Set when we could not even start (login failed): exit code 2, distinct from a
85
+ // scenario failure (1).
86
+ let loginFailed = false
57
87
  const record = (name: string, ok: boolean, note?: string): void => {
58
88
  results.push({ name, ok, note })
59
89
  console.log((ok ? 'PASS ' : 'FAIL ') + name + (note ? ' — ' + note : ''))
@@ -76,9 +106,33 @@ const browser = new DeepSeekBrowser({
76
106
  })
77
107
 
78
108
  async function main() {
79
- console.log('self-smoke: HOME=' + SMOKE_HOME + ' headed=' + HEADED)
109
+ console.log(
110
+ 'self-smoke: HOME=' +
111
+ SMOKE_HOME +
112
+ ' headed=' +
113
+ HEADED +
114
+ ' reuse-profile=' +
115
+ REUSE_PROFILE,
116
+ )
80
117
  await browser.launch()
81
- await browser.waitForLogin()
118
+ // waitForLogin() can block on a manual Enter in a pipe. Bound it so a cold
119
+ // login (anti-bot / captcha) fails fast with a clear hint instead of hanging
120
+ // until the caller's tool timeout. On failure exit code 2 signals "could not
121
+ // even start" — distinct from a scenario failure.
122
+ const loggedIn = await Promise.race([
123
+ browser.waitForLogin().then(() => true),
124
+ new Promise<boolean>((r) => setTimeout(() => r(false), 120_000)),
125
+ ])
126
+ if (!loggedIn) {
127
+ console.error(
128
+ '\nself-smoke: LOGIN FAILED — a cold login on a fresh profile is often\n' +
129
+ 'blocked by DeepSeek anti-bot/captcha. Re-run against a signed-in profile:\n' +
130
+ ' npm run self-smoke -- --reuse-profile\n' +
131
+ '(or sign in once with: npm run self-smoke -- --headed).',
132
+ )
133
+ loginFailed = true
134
+ return
135
+ }
82
136
  record('login', true, 'DeepSeek session is active')
83
137
 
84
138
  const tools = createTools(WORK, {})
@@ -252,4 +306,4 @@ if (!process.env.ZAMES_SMOKE_KEEP) {
252
306
  fs.rmSync(SMOKE_HOME, { recursive: true, force: true })
253
307
  } catch {}
254
308
  }
255
- process.exit(anyFail ? 1 : 0)
309
+ process.exit(loginFailed ? 2 : anyFail ? 1 : 0)