kaloko 0.10.0 → 0.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
package/src/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { loadConfig, environment, urlFor, baseUrl, CONFIG_FILE, LEGACY_CONFIG_FI
9
9
  import { loadScenario, parseScenario, validateScenario, scenarioWarnings } from './lib/scenario.mjs'
10
10
  import { createRun, resolveRun, readCaptures, listRuns } from './lib/run.mjs'
11
11
  import { gitMeta, projectAndIssue, appVersion } from './lib/git.mjs'
12
- import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
12
+ import { startBrowser, stopBrowser, connectBrowser, currentPage, launchEphemeral, launchWithRetry, consoleLog, allStates, resetSession, acquireWalkLock } from './lib/browser.mjs'
13
13
  import { captureStep, captureCrop, captureOptions, acceptDialogs } from './lib/capture.mjs'
14
14
  import { observeConsole } from './lib/console.mjs'
15
15
  import { mailClient, waitForMessage, recipient, readMessageFile } from './lib/mail.mjs'
@@ -1663,7 +1663,7 @@ const commands = {
1663
1663
  if (!account.configured) throw new UserError(`Add the account first: environments.${env.name}.accounts.${args.account} in ${CONFIG_FILE} (it may be empty: {}).`)
1664
1664
  if (!process.stdin.isTTY) throw new UserError('kaloko auth save needs a person at the terminal: they sign in in the browser window and press Enter.')
1665
1665
  const { chromium } = await import('playwright')
1666
- const browser = await chromium.launch({ headless: false, channel: config.browser.channel || undefined })
1666
+ const browser = await launchWithRetry(chromium, { headless: false, channel: config.browser.channel || undefined })
1667
1667
  try {
1668
1668
  const page = await (await newContext(browser, env, { root, config })).newPage()
1669
1669
  await page.goto(typeof args.url === 'string' ? args.url : baseUrl(env), { waitUntil: 'domcontentloaded' })
@@ -2274,7 +2274,8 @@ const commands = {
2274
2274
  const product = pickProduct(config, root, args.product)
2275
2275
  const printPdf = async (htmlFile, outFile) => {
2276
2276
  const { chromium } = await import('playwright')
2277
- const browser = await chromium.launch(config.browser?.channel ? { channel: config.browser.channel } : {}).catch(() => chromium.launch())
2277
+ // the configured channel when it is installed, else Playwright's Chromium; a browser that stalls is not retried twice over
2278
+ const browser = await launchWithRetry(chromium, config.browser?.channel ? { channel: config.browser.channel } : {}).catch((e) => e?.name === 'TimeoutError' || !config.browser?.channel ? Promise.reject(e) : launchWithRetry(chromium, {}))
2278
2279
  try {
2279
2280
  const page = await browser.newPage()
2280
2281
  await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load', timeout: 60000 })
@@ -200,10 +200,12 @@ export async function jsValues (page, checks, timeout = JS_TIMEOUT) {
200
200
  return { type, value }
201
201
  })()`
202
202
  try {
203
+ // the page's own timer limits the expression; this one only guards against a page that stopped answering, with
204
+ // room for a slow round trip on a busy machine (a 1 s margin failed true checks under load)
203
205
  let timer
204
206
  const result = await Promise.race([
205
207
  page.evaluate(wrapped),
206
- new Promise((_, no) => { timer = setTimeout(() => no(new Error(`did not finish within ${timeout} ms`)), timeout + 1000) })
208
+ new Promise((_, no) => { timer = setTimeout(() => no(new Error(`the page did not answer within ${Math.round((timeout + 15000) / 1000)} s`)), timeout + 15000) })
207
209
  ]).finally(() => clearTimeout(timer))
208
210
  const text = JSON.stringify(result.value)
209
211
  out[id] = { expression, result: text && text.length > 2000 ? { type: result.type, value: `${text.slice(0, 2000)}…` } : result }
@@ -5,10 +5,12 @@
5
5
  import { chromium } from 'playwright'
6
6
  import { writeJson, appendJsonl } from './util.mjs'
7
7
  import { prepareContext, clientCertificates } from './access.mjs'
8
+ import { launchPersistentWithRetry } from './browser.mjs'
8
9
 
9
10
  const opts = JSON.parse(process.argv[2])
10
11
  const certs = opts.env ? clientCertificates(opts.env, opts.root) : []
11
- const context = await chromium.launchPersistentContext(opts.profile, {
12
+ // a time limit and one more try (KALOKO_LAUNCH_TIMEOUT): a start that stalls on a busy machine does not hang here
13
+ const context = await launchPersistentWithRetry(chromium, opts.profile, {
12
14
  headless: !opts.headed,
13
15
  channel: opts.channel || undefined,
14
16
  viewport: { width: 1280, height: 800 },
@@ -69,12 +69,16 @@ export async function startBrowser ({ root, config, env, headed }) {
69
69
  })
70
70
  const child = spawn(process.execPath, [join(here, 'browser-daemon.mjs'), args], { detached: true, stdio: 'ignore' })
71
71
  child.unref()
72
- for (let i = 0; i < 100; i++) {
72
+ // the daemon launches with launchLimit() and tries once more after a timeout
73
+ const wait = 2 * launchLimit() + 10000
74
+ for (const until = Date.now() + wait; Date.now() < until;) {
73
75
  await new Promise((r) => setTimeout(r, 200))
74
76
  const state = await readJson(stateFile, null)
75
77
  if (state && state.pid === child.pid) return state
78
+ if (!await alive(child.pid)) throw new UserError('Browser did not start (its process ended). Try "kaloko browser start --headed" to see what happens, or "kaloko doctor".')
76
79
  }
77
- throw new UserError('Browser did not start within 20 s. Try "kaloko browser start --headed" to see what happens.')
80
+ try { process.kill(child.pid, 'SIGTERM') } catch {} // Playwright ends the browser it was starting
81
+ throw new UserError(`Browser did not start within ${Math.round(wait / 1000)} s. Try "kaloko browser start --headed" to see what happens; on a busy machine raise KALOKO_LAUNCH_TIMEOUT (seconds).`)
78
82
  }
79
83
 
80
84
  /** Stops the browser of one environment, or every shared browser of the project. */
@@ -102,7 +106,7 @@ export async function connectBrowser ({ root, config, env }) {
102
106
  if (!state) return null
103
107
  assertPolicy(state, env)
104
108
  const { chromium } = await import('playwright')
105
- const browser = await chromium.connectOverCDP(state.cdp)
109
+ const browser = await connectWithRetry(chromium, state.cdp)
106
110
  return { browser, context: browser.contexts()[0], state }
107
111
  }
108
112
 
@@ -138,17 +142,82 @@ export async function currentPage (context, match) {
138
142
  return real[real.length - 1] || pages[pages.length - 1] || context.newPage()
139
143
  }
140
144
 
145
+ /** The time limit of a browser launch or connection: KALOKO_LAUNCH_TIMEOUT seconds, default 45. */
146
+ export const launchLimit = () => (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
147
+ /** The time limit of the first context of a new browser and of every later newContext/newPage: 20 s, or KALOKO_LAUNCH_TIMEOUT when set. */
148
+ export const answerLimit = () => (Number(process.env.KALOKO_LAUNCH_TIMEOUT) > 0 ? launchLimit() : 20000)
149
+
150
+ const isTimeout = (e) => /Timeout|did not (finish|answer)/i.test(String(e?.name) + ' ' + String(e?.message))
151
+
152
+ /** `promise`, or a TimeoutError naming `what` when it has not settled within `ms`. */
153
+ export function withTimeout (promise, ms, what) {
154
+ let timer
155
+ const limit = new Promise((resolve, reject) => {
156
+ timer = setTimeout(() => reject(Object.assign(new Error(`${what} did not answer within ${Math.round(ms / 1000)} s (KALOKO_LAUNCH_TIMEOUT)`), { name: 'TimeoutError' })), ms)
157
+ })
158
+ return Promise.race([promise, limit]).finally(() => clearTimeout(timer))
159
+ }
160
+
161
+ /** Runs `attempt` and, when it timed out, once more; any other error reaches the caller. */
162
+ export async function retryOnTimeout (attempt) {
163
+ try { return await attempt() } catch (e) {
164
+ if (!isTimeout(e)) throw e
165
+ return attempt()
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Puts a time limit on newContext and newPage of a browser: a browser that stopped answering (seen on loaded
171
+ * machines: launched fine, then Target.createBrowserContext never returned) fails the command instead of holding it
172
+ * forever. A context that arrives after the limit is closed.
173
+ */
174
+ export function limitBrowser (browser, ms = answerLimit()) {
175
+ if (!browser || typeof browser.newContext !== 'function' || browser.__kalokoLimited) return browser
176
+ for (const name of ['newContext', 'newPage']) {
177
+ const call = browser[name].bind(browser)
178
+ browser[name] = (...a) => {
179
+ const pending = call(...a)
180
+ return withTimeout(pending, ms, `browser.${name}`).catch((e) => {
181
+ if (isTimeout(e)) pending.then((x) => x?.close?.().catch(() => {}), () => {})
182
+ throw e
183
+ })
184
+ }
185
+ }
186
+ browser.__kalokoLimited = true
187
+ return browser
188
+ }
189
+
141
190
  /**
142
191
  * Launches a browser with a time limit and one more try: on a busy machine a browser now and then starts but never
143
- * answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out.
144
- * KALOKO_LAUNCH_TIMEOUT (seconds, default 45) sets the limit.
192
+ * answers, and Playwright would wait for it forever. Playwright ends the process of a launch that timed out. A
193
+ * launched browser must open a context within answerLimit(); one that does not is closed (Playwright kills it when it
194
+ * does not close within 30 s) and the launch is tried once more. KALOKO_LAUNCH_TIMEOUT (seconds, default 45).
145
195
  */
146
196
  export async function launchWithRetry (browserType, options = {}) {
147
- const timeout = (Number(process.env.KALOKO_LAUNCH_TIMEOUT) || 45) * 1000
148
- try { return await browserType.launch({ timeout, ...options }) } catch (e) {
149
- if (!/Timeout/i.test(String(e?.name) + String(e?.message))) throw e
150
- return browserType.launch({ timeout, ...options })
151
- }
197
+ const timeout = launchLimit()
198
+ return retryOnTimeout(async () => {
199
+ const browser = await browserType.launch({ timeout, ...options })
200
+ if (typeof browser?.newContext !== 'function') return browser
201
+ try {
202
+ await withTimeout(browser.newContext().then((c) => c.close()), answerLimit(), 'the new browser')
203
+ } catch (e) {
204
+ browser.close().catch(() => {}) // not awaited: the next try starts while Playwright closes or kills this one
205
+ throw e
206
+ }
207
+ return limitBrowser(browser)
208
+ })
209
+ }
210
+
211
+ /** chromium.connectOverCDP with a time limit and one more try; the browser's newContext/newPage are limited too. */
212
+ export async function connectWithRetry (browserType, endpoint, options = {}) {
213
+ const timeout = launchLimit()
214
+ return limitBrowser(await retryOnTimeout(() => browserType.connectOverCDP(endpoint, { timeout, ...options })))
215
+ }
216
+
217
+ /** chromium.launchPersistentContext with a time limit and one more try (the shared browser's daemon). */
218
+ export async function launchPersistentWithRetry (browserType, profile, options = {}) {
219
+ const timeout = launchLimit()
220
+ return retryOnTimeout(() => browserType.launchPersistentContext(profile, { timeout, ...options }))
152
221
  }
153
222
 
154
223
  export async function launchEphemeral (config) {
@@ -237,7 +237,8 @@ async function defaultInstall (engine) {
237
237
 
238
238
  async function defaultEngine (engine) {
239
239
  const pw = await import('playwright')
240
- const browser = await pw[engine].launch({ headless: true })
240
+ const { launchWithRetry } = await import('./browser.mjs')
241
+ const browser = await launchWithRetry(pw[engine], { headless: true })
241
242
  const version = browser.version()
242
243
  await browser.close()
243
244
  return { version }
@@ -245,7 +246,8 @@ async function defaultEngine (engine) {
245
246
 
246
247
  async function defaultLaunch (config) {
247
248
  const { chromium } = await import('playwright')
248
- const browser = await chromium.launch({ headless: true, channel: config.browser?.channel || undefined })
249
+ const { launchWithRetry } = await import('./browser.mjs')
250
+ const browser = await launchWithRetry(chromium, { headless: true, channel: config.browser?.channel || undefined })
249
251
  const version = browser.version()
250
252
  await browser.close()
251
253
  return { version }
package/viewer/index.html CHANGED
@@ -2502,7 +2502,11 @@ function renderPresence () {
2502
2502
  // a phone shows two faces at most (one when the bar is tight), a computer six; the rest is a count
2503
2503
  const tight = !!$('presence').closest('.bar')?.classList.contains('is-tight'); const max = phoneBar() ? (tight ? 1 : 2) : tight ? 2 : 6;
2504
2504
  const faces = [...[...PR.agents.values()].map((x) => `<span class="pav pav--agent" style="--c:#151514" title="${esc(x.name)} ${esc(T('viaAgent'))} ${esc(x.email)}${x.step ? ` · ${esc(x.step)}` : ''}">AI</span>`), ...peers.map((p) => `<button class="pav" style="--c:${peerColor(p.email)}" data-follow="${esc(p.id)}" aria-pressed="${PR.follow === p.id}" title="${esc(p.email)} · ${esc(T('followHint'))}">${esc(initials(p.name || p.email))}</button>`)];
2505
+ // the list is redrawn on every presence update: keep keyboard focus on the same control instead of losing it
2506
+ const had = el.contains(document.activeElement) ? document.activeElement : null
2507
+ const keep = had && (had.dataset.follow ? `[data-follow="${CSS.escape(had.dataset.follow)}"]` : had.matches('[data-present]') ? '[data-present]' : had.matches('[data-vis]') ? '[data-vis]' : had.matches('[data-pmenu]') ? '[data-pmenu]' : null)
2505
2508
  el.innerHTML = faces.slice(0, max).join('') + (faces.length > max ? `<span class="muted">+${faces.length - max}</span>` : '') + menu;
2509
+ if (keep) el.querySelector(keep)?.focus({ preventScroll: true })
2506
2510
  requestAnimationFrame(fitBar);
2507
2511
  el.onclick = (e) => {
2508
2512
  if (e.target.closest('[data-pmenu]')) { e.stopPropagation(); S.closeMenus?.('pmenu'); PR.menu = !PR.menu; renderPresence(); placePop(el.querySelector('.pmenu')); return; }