@buildinternet/uploads 0.45.0 → 0.46.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.
@@ -82,7 +82,16 @@ Options:
82
82
  --no-hide-dev-tools Don't auto-hide framework dev toolbars (auto-hidden on localhost/private)
83
83
  --reduced-motion Emulate prefers-reduced-motion: reduce so animations settle (best-effort
84
84
  on --via remote — neutralizes animations via injected CSS)
85
- --eval <js> Run JS in the page after settle, before capture (--via local only)
85
+ --wait-for <js> Poll this JS expression in the page until truthy before --eval and
86
+ capture (--via local only). Bridges framework hydration: load/
87
+ networkidle settle before React/Next attach handlers, so a synthetic
88
+ click in --eval hits the inert server-rendered DOM. Express the app's
89
+ own "interactive" signal, e.g. --wait-for 'window.__hydrated===true' or
90
+ --wait-for 'document.querySelector("[data-hydrated]")'. Times out with
91
+ the capture timeout if it never becomes truthy.
92
+ --eval <js> Run JS in the page after settle, before capture (--via local only).
93
+ Note: synthetic events (el.click()) won't reach framework handlers
94
+ until the app hydrates — pair with --wait-for on React/Next apps.
86
95
  --init-script <file> Inject a JS file before navigation (--via local only)
87
96
  --annotate <file|-> Bake hand-drawn boxes, arrows, labels, and redactions from a JSON
88
97
  annotation spec onto the capture before upload (file path or - for
@@ -216,6 +225,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
216
225
  // lets captureScreenshot apply its localhost-aware default.
217
226
  const hideDevTools = flagBool(parsed.flags, "--no-hide-dev-tools") ? false : undefined;
218
227
  const reducedMotion = flagBool(parsed.flags, "--reduced-motion");
228
+ const waitForExpr = flagString(parsed.flags, "--wait-for");
219
229
  const evalJs = flagString(parsed.flags, "--eval");
220
230
  const initScriptPath = flagString(parsed.flags, "--init-script");
221
231
  let initScript;
@@ -445,6 +455,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
445
455
  hide,
446
456
  hideDevTools,
447
457
  reducedMotion,
458
+ waitForExpr,
448
459
  evalJs,
449
460
  initScript,
450
461
  // Skip folding when an explicit --key was given — --key sets the whole
@@ -62,6 +62,15 @@ export interface LocalCaptureOptions {
62
62
  hide?: string[];
63
63
  /** Emulate prefers-reduced-motion: reduce so animations settle deterministically. */
64
64
  reducedMotion?: boolean;
65
+ /**
66
+ * JS expression polled in the page (page.waitForFunction) after settle and
67
+ * before `evalJs`/capture — the caller's "app is interactive" signal. Lets a
68
+ * synthetic click in `evalJs` land after a framework (React/Next/…) has
69
+ * hydrated and attached its handlers, instead of firing on the still-inert
70
+ * server-rendered DOM. Throws `RENDER_FAILED` if it never becomes truthy
71
+ * within the capture timeout.
72
+ */
73
+ waitForExpr?: string;
65
74
  /** JS run via page.evaluate after settle, before capture. */
66
75
  evalJs?: string;
67
76
  /** JS injected via addInitScript before navigation. */
@@ -346,9 +346,28 @@ export async function captureLocal(opts) {
346
346
  }
347
347
  }
348
348
  const waitUntil = typeof opts.waitUntil === "string" ? opts.waitUntil : "load";
349
- await page.goto(opts.url, { waitUntil, timeout: opts.timeoutMs ?? 30_000 });
349
+ const timeoutMs = opts.timeoutMs ?? 30_000;
350
+ await page.goto(opts.url, { waitUntil, timeout: timeoutMs });
350
351
  if (typeof opts.waitUntil === "number")
351
352
  await page.waitForTimeout(opts.waitUntil);
353
+ // Hydration-aware gate (issue #715): poll the caller's "app is interactive"
354
+ // predicate before running any eval or capturing. The load/networkidle
355
+ // settle strategies fire before a framework hydrates, so a synthetic
356
+ // click in `evalJs` would hit the inert server-rendered DOM with no
357
+ // handler attached. Waiting for the caller's own signal (e.g.
358
+ // `window.__hydrated === true`, or a class/attribute the app sets once
359
+ // interactive) closes that gap. A timeout means the predicate never
360
+ // became truthy — surface it clearly rather than capturing the un-ready
361
+ // page silently.
362
+ if (opts.waitForExpr) {
363
+ try {
364
+ await page.waitForFunction(opts.waitForExpr, undefined, { timeout: timeoutMs });
365
+ }
366
+ catch (err) {
367
+ throw new UploadsError(`--wait-for expression never became truthy within ${timeoutMs}ms: ${opts.waitForExpr}` +
368
+ ` (${err instanceof Error ? err.message : String(err)})`, "RENDER_FAILED");
369
+ }
370
+ }
352
371
  // Hide overlays first, then run any user eval (which may depend on, or
353
372
  // deliberately override, the hidden state).
354
373
  if (opts.hide && opts.hide.length > 0) {
@@ -99,6 +99,13 @@ export interface CaptureScreenshotOptions {
99
99
  hideDevTools?: boolean;
100
100
  /** Emulate prefers-reduced-motion: reduce so CSS/JS animations settle. */
101
101
  reducedMotion?: boolean;
102
+ /**
103
+ * JS expression polled in the page until truthy after settle, before
104
+ * `evalJs` and capture — the caller's "app is interactive" signal so a
105
+ * synthetic click in `evalJs` lands after framework hydration (issue #715).
106
+ * Local backend only — throws if the resolved backend is remote.
107
+ */
108
+ waitForExpr?: string;
102
109
  /** Run this JS in the page after settle, before capture (local backend only). */
103
110
  evalJs?: string;
104
111
  /** Inject this JS as an init script before navigation (local backend only). */
@@ -127,6 +134,7 @@ export interface CaptureScreenshotOptions {
127
134
  waitUntil: WaitUntil;
128
135
  hide?: string[];
129
136
  reducedMotion?: boolean;
137
+ waitForExpr?: string;
130
138
  evalJs?: string;
131
139
  initScript?: string;
132
140
  measureSelectors?: string[];
@@ -290,6 +290,12 @@ export async function captureScreenshot(opts) {
290
290
  if (backend === "remote" && (opts.evalJs !== undefined || opts.initScript !== undefined)) {
291
291
  throw new UploadsError("--eval and --init-script are local-only — use --via local", "USAGE");
292
292
  }
293
+ // --wait-for polls a JS predicate via the live local page (page.waitForFunction);
294
+ // the remote renderer has no eval escape hatch to evaluate it. Fail fast
295
+ // rather than silently ignore the caller's readiness signal.
296
+ if (backend === "remote" && opts.waitForExpr !== undefined) {
297
+ throw new UploadsError("--wait-for is local-only — use --via local", "USAGE");
298
+ }
293
299
  // Selector-based annotation measurement needs a live local page — the
294
300
  // remote render endpoint has no eval escape hatch to run
295
301
  // getBoundingClientRect. Covers both explicit --via remote and auto
@@ -315,6 +321,7 @@ export async function captureScreenshot(opts) {
315
321
  waitUntil,
316
322
  hide,
317
323
  reducedMotion: opts.reducedMotion,
324
+ waitForExpr: opts.waitForExpr,
318
325
  evalJs: opts.evalJs,
319
326
  initScript: opts.initScript,
320
327
  measureSelectors: opts.measureSelectors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,