@buildinternet/uploads 0.44.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.
- package/dist/commands/screenshot.js +12 -1
- package/dist/commands.js +65 -2
- package/dist/github-gh.d.ts +27 -0
- package/dist/github-gh.js +66 -0
- package/dist/screenshot-local.d.ts +9 -0
- package/dist/screenshot-local.js +20 -1
- package/dist/screenshot.d.ts +8 -0
- package/dist/screenshot.js +7 -0
- package/package.json +1 -1
|
@@ -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
|
-
--
|
|
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
|
package/dist/commands.js
CHANGED
|
@@ -14,7 +14,7 @@ import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
|
14
14
|
import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
|
|
15
15
|
import { mergeSidecarMeta } from "./sidecar.js";
|
|
16
16
|
import { ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, ghBranchKeyPrefix, ghKeyPrefix, ghPrivateKeyPrefix, ghPrivateBranchKeyPrefix, ghMetadataFromTarget, parseGhKey, parseGhPrivateKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
|
|
17
|
-
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
17
|
+
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, hasLinkCandidate, extractCandidateUrls, fetchAdoptionCandidateText, } from "./github-gh.js";
|
|
18
18
|
import { deriveRepoFromGit, deriveRepoSlugFromGit } from "./keys.js";
|
|
19
19
|
import { noProjectContextNudge } from "./project-context-nudge.js";
|
|
20
20
|
import { resolvePutPrefix } from "./destinations.js";
|
|
@@ -564,6 +564,59 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
564
564
|
`Posting via local gh in the meantime.\n`);
|
|
565
565
|
}
|
|
566
566
|
}
|
|
567
|
+
// Link adoption (issue #708): local-gh fallback parity with the bot's own
|
|
568
|
+
// adoption (issue #701, apps/api/src/github-link-adopt.ts). When the bot
|
|
569
|
+
// already handled this target the server already adopted for us, so this
|
|
570
|
+
// only runs once we've fallen through to gh. Scans the PR/issue body and
|
|
571
|
+
// every comment for pasted uploads.sh URLs and adopts each one that
|
|
572
|
+
// resolves (server-side, inside `POST .../github/attach`) to a file in
|
|
573
|
+
// THIS workspace's own bound-repo attachment prefix — copy, never move,
|
|
574
|
+
// same as the bot path. Best-effort end to end: any failure (not a git
|
|
575
|
+
// repo, `gh` unavailable/unauthenticated, config unreadable) degrades to
|
|
576
|
+
// "adopt nothing" rather than blocking the comment sync it rides along
|
|
577
|
+
// with.
|
|
578
|
+
let adoptedCount = 0;
|
|
579
|
+
let preAdoptionAttachmentCount;
|
|
580
|
+
try {
|
|
581
|
+
const root = run("git", ["rev-parse", "--show-toplevel"]).trim();
|
|
582
|
+
const { config: adoptConfig } = readLocalRepoCommentConfig(root);
|
|
583
|
+
const { options: adoptOptions } = resolveCommentOptions(adoptConfig, null);
|
|
584
|
+
if (adoptOptions.adoptLinkedFiles) {
|
|
585
|
+
const text = fetchAdoptionCandidateText(target, run);
|
|
586
|
+
if (hasLinkCandidate(text)) {
|
|
587
|
+
const urls = extractCandidateUrls(text);
|
|
588
|
+
if (urls.length > 0) {
|
|
589
|
+
// Baseline BEFORE this pass's adoptions land (mirrors the bot
|
|
590
|
+
// path's `gatherCommentBody` call before its own copies) — feeds
|
|
591
|
+
// the noise guard below without a lone adoption inflating its own
|
|
592
|
+
// count. Plain prefix only (not the private-prefix listing done
|
|
593
|
+
// for the final render below) — good enough for a guard decision.
|
|
594
|
+
preAdoptionAttachmentCount = (await client.listAll({ prefix: ghKeyPrefix(target) }))
|
|
595
|
+
.length;
|
|
596
|
+
for (const url of urls) {
|
|
597
|
+
try {
|
|
598
|
+
await client.attachExisting({
|
|
599
|
+
source: url,
|
|
600
|
+
repo: target.repo,
|
|
601
|
+
...(target.kind === "pull" ? { pr: target.num } : { issue: target.num }),
|
|
602
|
+
});
|
|
603
|
+
adoptedCount++;
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// Not a resolvable uploads.sh URL, belongs to a different
|
|
607
|
+
// workspace, or the source was deleted — silently dropped,
|
|
608
|
+
// matching the bot path's contract (a throw from
|
|
609
|
+
// `resolveAttachSourceKey` is caught per-URL there too).
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
// Not a git repo, `.uploads.yml` unreadable, or `gh` unavailable for the
|
|
618
|
+
// PR/comments fetch — degrade to no adoption this pass.
|
|
619
|
+
}
|
|
567
620
|
// gh fallback: gather from this workspace's own data and post via local `gh`.
|
|
568
621
|
// Note (issues #304, #365): this CLI process has no server-side
|
|
569
622
|
// WorkspaceRecord in scope, so it cannot honor a workspace's
|
|
@@ -658,11 +711,21 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
658
711
|
// bot identity, so this note would be wrong there.
|
|
659
712
|
const body = `${attachmentsCommentBody(items, previewGalleries, marker, renderOptions, target)}\n${GH_FALLBACK_AUTHOR_NOTE}`;
|
|
660
713
|
const count = items.length + previewGalleries.length;
|
|
714
|
+
// Noise guard (issue #708, mirrors the bot's `shouldSyncAfterAdopt`): a
|
|
715
|
+
// lone adopted link with nothing else already attached is already fully
|
|
716
|
+
// visible inline in the PR/comment — don't create a brand-new comment just
|
|
717
|
+
// to repeat it. `upsertAttachmentsComment` still PATCHes an existing
|
|
718
|
+
// managed comment unconditionally (its own `if (existing)` branch runs
|
|
719
|
+
// regardless of `createIfMissing`), so "a managed comment already exists"
|
|
720
|
+
// and "other attachments are already present" both heal/sync for free
|
|
721
|
+
// here without any extra condition — this only ever suppresses a fresh
|
|
722
|
+
// create.
|
|
723
|
+
const skipLoneAdoptionCreate = adoptedCount === 1 && (preAdoptionAttachmentCount ?? 0) === 0;
|
|
661
724
|
// Empty (count 0) renders the neutral empty-state body but must not create a
|
|
662
725
|
// comment — it only rewrites one that already exists (`action: "skipped"`
|
|
663
726
|
// when none does).
|
|
664
727
|
const { action } = upsertAttachmentsComment(target, body, run, marker, {
|
|
665
|
-
createIfMissing: count > 0,
|
|
728
|
+
createIfMissing: count > 0 && !skipLoneAdoptionCreate,
|
|
666
729
|
});
|
|
667
730
|
return { action, count, via: "gh" };
|
|
668
731
|
}
|
package/dist/github-gh.d.ts
CHANGED
|
@@ -98,3 +98,30 @@ export declare function upsertAttachmentsComment(target: GhTarget, body: string,
|
|
|
98
98
|
}): {
|
|
99
99
|
action: "created" | "updated" | "skipped";
|
|
100
100
|
};
|
|
101
|
+
/** Cheap, regex-only reject: no http(s) URL at all means nothing to scan for.
|
|
102
|
+
* Mirrors `hasLinkCandidate` in apps/api/src/github-link-adopt.ts. */
|
|
103
|
+
export declare function hasLinkCandidate(text: string): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Distinct http(s) URLs found in `text`, trailing prose punctuation
|
|
106
|
+
* stripped, order preserved, first occurrence wins on duplicates. Ported
|
|
107
|
+
* verbatim from apps/api/src/github-link-adopt.ts's `extractCandidateUrls`
|
|
108
|
+
* so the two paths recognize the same URL spellings — resolution itself
|
|
109
|
+
* (storage host / embed host / `/f/` page → key, and the bound-workspace
|
|
110
|
+
* check) happens server-side inside `POST .../github/attach`, so this file
|
|
111
|
+
* doesn't need its own copy of that logic.
|
|
112
|
+
*/
|
|
113
|
+
export declare function extractCandidateUrls(text: string): string[];
|
|
114
|
+
/**
|
|
115
|
+
* Best-effort concatenation of a PR/issue's current body plus every comment's
|
|
116
|
+
* current body, for link-adoption scanning. Returns "" on any `gh` failure
|
|
117
|
+
* (not authenticated, network, repo/number not found) — adoption degrades to
|
|
118
|
+
* a no-op rather than blocking the comment sync it rides along with.
|
|
119
|
+
*
|
|
120
|
+
* Unlike the webhook path (which re-scans one specific body/comment ref per
|
|
121
|
+
* event), the CLI has no per-event ref to key off of — `uploads comment` is
|
|
122
|
+
* one ad-hoc invocation — so this scans the PR/issue body and every comment
|
|
123
|
+
* on the thread in one pass every time it runs. That's a documented
|
|
124
|
+
* divergence: harmless (adoption is idempotent) but does mean a link posted
|
|
125
|
+
* in comment #1 gets rescanned on every later `uploads comment` run too.
|
|
126
|
+
*/
|
|
127
|
+
export declare function fetchAdoptionCandidateText(target: GhTarget, run: CommandRunner): string;
|
package/dist/github-gh.js
CHANGED
|
@@ -344,6 +344,72 @@ function reconcileAfterCreate(target, body, run, marker, createdRaw) {
|
|
|
344
344
|
return null;
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
|
+
// --- link adoption (issue #708, local-gh fallback parity with the bot's
|
|
348
|
+
// #701/apps/api/src/github-link-adopt.ts) ---
|
|
349
|
+
/** Cheap, regex-only reject: no http(s) URL at all means nothing to scan for.
|
|
350
|
+
* Mirrors `hasLinkCandidate` in apps/api/src/github-link-adopt.ts. */
|
|
351
|
+
export function hasLinkCandidate(text) {
|
|
352
|
+
return /https?:\/\//i.test(text);
|
|
353
|
+
}
|
|
354
|
+
const URL_RE = /https?:\/\/[^\s)"'<>\]]+/gi;
|
|
355
|
+
/**
|
|
356
|
+
* Distinct http(s) URLs found in `text`, trailing prose punctuation
|
|
357
|
+
* stripped, order preserved, first occurrence wins on duplicates. Ported
|
|
358
|
+
* verbatim from apps/api/src/github-link-adopt.ts's `extractCandidateUrls`
|
|
359
|
+
* so the two paths recognize the same URL spellings — resolution itself
|
|
360
|
+
* (storage host / embed host / `/f/` page → key, and the bound-workspace
|
|
361
|
+
* check) happens server-side inside `POST .../github/attach`, so this file
|
|
362
|
+
* doesn't need its own copy of that logic.
|
|
363
|
+
*/
|
|
364
|
+
export function extractCandidateUrls(text) {
|
|
365
|
+
const seen = new Set();
|
|
366
|
+
const out = [];
|
|
367
|
+
for (const m of text.matchAll(URL_RE)) {
|
|
368
|
+
const url = m[0].replace(/[.,;:]+$/, "");
|
|
369
|
+
if (seen.has(url))
|
|
370
|
+
continue;
|
|
371
|
+
seen.add(url);
|
|
372
|
+
out.push(url);
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Best-effort concatenation of a PR/issue's current body plus every comment's
|
|
378
|
+
* current body, for link-adoption scanning. Returns "" on any `gh` failure
|
|
379
|
+
* (not authenticated, network, repo/number not found) — adoption degrades to
|
|
380
|
+
* a no-op rather than blocking the comment sync it rides along with.
|
|
381
|
+
*
|
|
382
|
+
* Unlike the webhook path (which re-scans one specific body/comment ref per
|
|
383
|
+
* event), the CLI has no per-event ref to key off of — `uploads comment` is
|
|
384
|
+
* one ad-hoc invocation — so this scans the PR/issue body and every comment
|
|
385
|
+
* on the thread in one pass every time it runs. That's a documented
|
|
386
|
+
* divergence: harmless (adoption is idempotent) but does mean a link posted
|
|
387
|
+
* in comment #1 gets rescanned on every later `uploads comment` run too.
|
|
388
|
+
*/
|
|
389
|
+
export function fetchAdoptionCandidateText(target, run) {
|
|
390
|
+
let body = "";
|
|
391
|
+
try {
|
|
392
|
+
body = run("gh", ["api", `repos/${target.repo}/issues/${target.num}`, "--jq", '.body // ""']);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// Not found / no access — fall through with an empty body; comments may
|
|
396
|
+
// still be readable.
|
|
397
|
+
}
|
|
398
|
+
let comments = "";
|
|
399
|
+
try {
|
|
400
|
+
comments = run("gh", [
|
|
401
|
+
"api",
|
|
402
|
+
`repos/${target.repo}/issues/${target.num}/comments?per_page=100`,
|
|
403
|
+
"--paginate",
|
|
404
|
+
"--jq",
|
|
405
|
+
'[.[].body] | join("\\n")',
|
|
406
|
+
]);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Same degrade — an empty comments blob just means nothing more to scan.
|
|
410
|
+
}
|
|
411
|
+
return `${body}\n${comments}`;
|
|
412
|
+
}
|
|
347
413
|
/** PATCH one comment's body via stdin, so the body is never shell-interpolated. */
|
|
348
414
|
function patchComment(target, run, id, body) {
|
|
349
415
|
run("gh", ["api", `repos/${target.repo}/issues/comments/${id}`, "-X", "PATCH", "-F", "body=@-"], body);
|
|
@@ -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. */
|
package/dist/screenshot-local.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/screenshot.d.ts
CHANGED
|
@@ -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[];
|
package/dist/screenshot.js
CHANGED
|
@@ -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,
|