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 +1 -1
- package/src/cli.mjs +4 -3
- package/src/lib/assertions.mjs +3 -1
- package/src/lib/browser-daemon.mjs +3 -1
- package/src/lib/browser.mjs +79 -10
- package/src/lib/doctor.mjs +4 -2
- package/viewer/index.html +4 -0
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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 })
|
package/src/lib/assertions.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 },
|
package/src/lib/browser.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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 = (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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) {
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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; }
|