diff --git a/website-to-components/jobs/00-sitemap.js b/website-to-components/jobs/00-sitemap.js index 98509c7..82fb288 100644 --- a/website-to-components/jobs/00-sitemap.js +++ b/website-to-components/jobs/00-sitemap.js @@ -12,6 +12,7 @@ import { spawnSync, execSync } from "child_process"; import { writeFileSync, readFileSync, existsSync } from "fs"; import { sitePaths, siteSlug, pageSlug, ensureDir } from "../lib/paths.js"; +import { browserOpen } from "../lib/browser.js"; function browserEval(js) { const result = spawnSync("agent-browser", ["eval", "--stdin"], { @@ -142,7 +143,7 @@ export async function run(url) { console.log(`Detecting main menu for ${host} …`); execSync("agent-browser set viewport 1440 900", { stdio: "inherit" }); - execSync(`agent-browser open "${url}"`, { stdio: "inherit" }); + browserOpen(url); execSync("agent-browser wait --load networkidle", { stdio: "inherit" }); // First pass: ask agent-browser for the accessibility snapshot so we can pick diff --git a/website-to-components/jobs/01-screenshot.js b/website-to-components/jobs/01-screenshot.js index 511c64c..424a6c0 100644 --- a/website-to-components/jobs/01-screenshot.js +++ b/website-to-components/jobs/01-screenshot.js @@ -3,6 +3,7 @@ import { writeFileSync } from "fs"; import { sitePaths, ensureDir } from "../lib/paths.js"; import { paintVideoIframes } from "../lib/video-iframes.js"; import { measureSections } from "./01c-measure-sections.js"; +import { browserOpen } from "../lib/browser.js"; function browserEval(js) { const result = spawnSync("agent-browser", ["eval", "--stdin"], { @@ -19,7 +20,7 @@ export async function run(url) { console.log(`Opening ${url}...`); execSync("agent-browser set viewport 1440 900", { stdio: "inherit" }); - execSync(`agent-browser open "${url}"`, { stdio: "inherit" }); + browserOpen(url); execSync("agent-browser wait --load networkidle", { stdio: "inherit" }); // Dismiss cookie banner using AI vision on the accessibility tree. diff --git a/website-to-components/jobs/01b-screenshot-mobile.js b/website-to-components/jobs/01b-screenshot-mobile.js index ad4b80a..e273f7d 100644 --- a/website-to-components/jobs/01b-screenshot-mobile.js +++ b/website-to-components/jobs/01b-screenshot-mobile.js @@ -14,6 +14,7 @@ import { writeFileSync, readFileSync } from "fs"; import { imageSize } from "../lib/image.js"; import { sitePaths, ensureDir } from "../lib/paths.js"; import { paintVideoIframes } from "../lib/video-iframes.js"; +import { browserOpen } from "../lib/browser.js"; const MOBILE_WIDTH = 390; const MOBILE_HEIGHT = 844; @@ -53,7 +54,7 @@ export async function run(url) { // at 390px — responsive sites that branch on the initial width (JS, not just // CSS media queries) need the reload, not just a post-load resize. console.log(`Opening ${url} to establish page context...`); - execSync(`agent-browser open "${url}"`, { stdio: "inherit" }); + browserOpen(url); execSync("agent-browser wait --load networkidle", { stdio: "inherit" }); console.log(`Setting mobile viewport ${MOBILE_WIDTH}x${MOBILE_HEIGHT} @${MOBILE_SCALE}x...`); @@ -63,7 +64,7 @@ export async function run(url) { // not a 390 @2x render. execSync(`agent-browser set viewport ${MOBILE_WIDTH} ${MOBILE_HEIGHT} ${MOBILE_SCALE}`, { stdio: "inherit" }); console.log("Reloading at mobile viewport..."); - execSync(`agent-browser open "${url}"`, { stdio: "inherit" }); + browserOpen(url); execSync("agent-browser wait --load networkidle", { stdio: "inherit" }); execSync("agent-browser wait 500", { stdio: "pipe" }); diff --git a/website-to-components/jobs/01c-measure-sections.js b/website-to-components/jobs/01c-measure-sections.js index 684d3e8..fa0c36e 100644 --- a/website-to-components/jobs/01c-measure-sections.js +++ b/website-to-components/jobs/01c-measure-sections.js @@ -25,6 +25,7 @@ import { spawnSync, execSync } from "child_process"; import { writeFileSync } from "fs"; import { sitePaths, ensureDir } from "../lib/paths.js"; +import { browserOpen } from "../lib/browser.js"; function browserEval(js) { const result = spawnSync("agent-browser", ["eval", "--stdin"], { @@ -216,7 +217,7 @@ export function measureSections(url) { // Standalone entry: requires an already-open session. Opens the URL if asked. export async function run(url, { open = false } = {}) { if (open) { - execSync(`agent-browser open "${url}"`, { stdio: "inherit" }); + browserOpen(url); execSync("agent-browser wait --load networkidle", { stdio: "inherit" }); } return measureSections(url); diff --git a/website-to-components/jobs/03b-extract-assets.js b/website-to-components/jobs/03b-extract-assets.js index fec9fca..ef4cec9 100644 --- a/website-to-components/jobs/03b-extract-assets.js +++ b/website-to-components/jobs/03b-extract-assets.js @@ -20,6 +20,7 @@ import { execSync } from 'child_process'; import { writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { sitePaths } from '../lib/paths.js'; +import { browserOpen } from "../lib/browser.js"; export async function run(url) { const { outputDir: outDir } = sitePaths(url); @@ -237,7 +238,7 @@ JSON.stringify(r, null, 2); // this scroll-and-wait step, the extractor will report 0 videos on any // page that lazy-loads its players. try { - execSync(`agent-browser open ${url}`, { encoding: 'utf8', timeout: 30000 }); + browserOpen(url, { stdio: 'pipe', encoding: 'utf8', timeout: 30000 }); } catch (err) { // Ignore — page may already be open } diff --git a/website-to-components/jobs/03b-scan-dom-images.js b/website-to-components/jobs/03b-scan-dom-images.js index 690dde8..766bad7 100644 --- a/website-to-components/jobs/03b-scan-dom-images.js +++ b/website-to-components/jobs/03b-scan-dom-images.js @@ -28,6 +28,7 @@ import { execSync } from 'child_process'; import { readFileSync, writeFileSync, mkdirSync, existsSync, createWriteStream } from 'fs'; import { join, basename, extname } from 'path'; import { sitePaths, siteSlug, ROOT } from '../lib/paths.js'; +import { browserOpen } from "../lib/browser.js"; // How long to wait (ms) after page load for JS widgets to inject their DOM const SETTLE_MS = 4000; @@ -39,7 +40,7 @@ export async function run(url) { console.log(` Opening: ${url}`); try { - execSync(`agent-browser open ${JSON.stringify(url)}`, { encoding: 'utf8', timeout: 30000 }); + browserOpen(url, { stdio: 'pipe', encoding: 'utf8', timeout: 30000 }); } catch (err) { // Page may already be open — continue } diff --git a/website-to-components/jobs/05-sitemap.js b/website-to-components/jobs/05-sitemap.js index a24e791..0aef7cf 100644 --- a/website-to-components/jobs/05-sitemap.js +++ b/website-to-components/jobs/05-sitemap.js @@ -1,6 +1,7 @@ import { spawnSync, execSync } from "child_process"; import { writeFileSync, readFileSync, existsSync } from "fs"; import { sitePaths, siteSlug, ensureDir } from "../lib/paths.js"; +import { browserOpen } from "../lib/browser.js"; function browserEval(js) { const result = spawnSync("agent-browser", ["eval", "--stdin"], { @@ -21,7 +22,7 @@ export async function run(url) { console.log(`Discovering sitemap for ${slug} (same-domain only)...`); execSync("agent-browser set viewport 1440 900", { stdio: "inherit" }); - execSync(`agent-browser open "${url}"`, { stdio: "inherit" }); + browserOpen(url); execSync("agent-browser wait --load networkidle", { stdio: "inherit" }); // Extract all anchor hrefs from the page, keep only same-domain links diff --git a/website-to-components/jobs/06-mobile-check.js b/website-to-components/jobs/06-mobile-check.js index 413d4fc..86ea250 100644 --- a/website-to-components/jobs/06-mobile-check.js +++ b/website-to-components/jobs/06-mobile-check.js @@ -27,6 +27,7 @@ import { writeFileSync, readFileSync, existsSync, readdirSync } from "fs"; import { join } from "path"; import { imageSize, cropPng } from "../lib/image.js"; import { sitePaths, ensureDir } from "../lib/paths.js"; +import { browserOpen } from "../lib/browser.js"; const MOBILE_WIDTH = 390; const MOBILE_HEIGHT = 844; @@ -51,7 +52,7 @@ function openStory(componentName, storyName = "Default") { const componentId = componentName.toLowerCase().replace(/_/g, "-"); const storyId = storyName.toLowerCase().replace(/\s+/g, "-"); const storybookUrl = `http://localhost:${STORYBOOK_PORT}/iframe.html?id=components-${componentId}--${storyId}&viewMode=story`; - execSync(`agent-browser open "${storybookUrl}"`, { stdio: "pipe" }); + browserOpen(storybookUrl, { stdio: "pipe" }); execSync("agent-browser wait --load networkidle", { stdio: "pipe" }); execSync("agent-browser wait 1000", { stdio: "pipe" }); } diff --git a/website-to-components/lib/browser.js b/website-to-components/lib/browser.js new file mode 100644 index 0000000..1aee2e0 --- /dev/null +++ b/website-to-components/lib/browser.js @@ -0,0 +1,52 @@ +/** + * Shared agent-browser process helpers. + * + * Why this exists: several jobs end with `agent-browser close`, and the next + * job in the pipeline starts with `agent-browser open`. Closing tears down the + * daemon's unix socket, and an `open` issued immediately after can arrive + * before the daemon has been re-created: + * + * ✗ Could not configure browser: Failed to connect: No such file or + * directory (os error 2) + * + * Measured at roughly a 50% failure rate on a bare close→open sequence, and 0% + * once a short settle delay is inserted. Rather than sprinkle sleeps after every + * close, retry the open — that also covers the daemon dying for any other + * reason, not just the teardown race. + */ + +import { execFileSync } from "child_process"; + +/** Block the event loop for `ms` — these jobs are strictly sequential. */ +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Run `agent-browser open `, retrying on failure with a linear backoff. + * + * @param {string} url URL to open. + * @param {object} [opts] execFileSync options, plus `attempts` (default 4). + * @returns {string|Buffer} Whatever execFileSync returns (honours `encoding`). + */ +export function browserOpen(url, opts = {}) { + const { attempts = 4, stdio = "inherit", ...execOpts } = opts; + let lastError; + + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + // argv form, no shell: a URL can never be interpreted as shell syntax. + return execFileSync("agent-browser", ["open", url], { stdio, ...execOpts }); + } + catch (error) { + lastError = error; + if (attempt === attempts) break; + // Linear backoff: 300ms, 600ms, 900ms. The observed race clears well + // inside the first step; the later ones cover a slower daemon restart. + console.log(` agent-browser open failed (attempt ${attempt}/${attempts}); retrying...`); + sleepSync(300 * attempt); + } + } + + throw lastError; +}