@storyshelf/core 0.8.0 → 0.9.0-next-d2cc082
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/adapter/capture-runner.d.mts +2 -1
- package/dist/adapter/capture-runner.mjs +2 -1
- package/dist/adapters/remote-browser.d.mts +8 -0
- package/dist/adapters/remote-browser.mjs +2 -0
- package/dist/adapters/remote-browser.mjs.map +1 -1
- package/dist/adapters/remote-sizing.d.mts +44 -0
- package/dist/adapters/remote-sizing.mjs +53 -0
- package/dist/adapters/remote-sizing.mjs.map +1 -0
- package/dist/capture/db-queue/index.mjs +1 -1
- package/dist/capture/fingerprint.d.mts +19 -6
- package/dist/capture/fingerprint.mjs +43 -13
- package/dist/capture/fingerprint.mjs.map +1 -1
- package/dist/capture/pipeline.mjs +7 -4
- package/dist/capture/pipeline.mjs.map +1 -1
- package/dist/models/design-health.d.mts +16 -1
- package/dist/models/design-health.mjs +20 -1
- package/dist/models/design-health.mjs.map +1 -1
- package/dist/models/design-nodes.d.mts +9 -0
- package/dist/models/design-nodes.mjs +17 -1
- package/dist/models/design-nodes.mjs.map +1 -1
- package/dist/models.d.mts +2 -2
- package/dist/models.mjs +2 -2
- package/dist/schema/design.d.mts +2 -0
- package/dist/test-helpers/fake-tables.mjs +1 -0
- package/dist/test-helpers/fake-tables.mjs.map +1 -1
- package/package.json +3 -12
|
@@ -8,6 +8,7 @@ import { HeldConnection } from "../adapters/remote-lease-held.mjs";
|
|
|
8
8
|
import { RemoteConnectError, RemoteConnectErrorOptions } from "../adapters/remote-lease-error.mjs";
|
|
9
9
|
import { onRemoteShutdown, shutdownRemote } from "../adapters/remote-lease-shutdown.mjs";
|
|
10
10
|
import { ConnectLeasedContext, ConnectLeasedOptions, connectLeased } from "../adapters/remote-lease.mjs";
|
|
11
|
+
import { RemoteSizing, UNVERIFIED_NOTE, logRemoteSizing, remoteSizingOf } from "../adapters/remote-sizing.mjs";
|
|
11
12
|
import { PoolLease, RefCounted, RefCountedOptions, refCounted } from "../adapters/remote-pool.mjs";
|
|
12
13
|
import { RenderRuns } from "../adapters/render-runs.mjs";
|
|
13
14
|
import { Adapter } from "./metadata.mjs";
|
|
@@ -208,5 +209,5 @@ interface CaptureRunner extends Adapter<{
|
|
|
208
209
|
*/
|
|
209
210
|
cancel(buildId: string, runId?: string): Promise<void>;
|
|
210
211
|
}
|
|
211
|
-
export { BROWSER_NAMES, type BrowserName, CaptureEnvironment, CaptureRunner, type CaptureTarget, type ConnectLeasedContext, type ConnectLeasedOptions, DEFAULT_BROWSER, type HeldConnection, type JobStatus, type ParsedTarget, type PoolLease, type RefCounted, type RefCountedOptions, type RemoteBrowser, type RemoteConnectContext, RemoteConnectError, type RemoteConnectErrorOptions, type RemoteConnection, type RemoteLeaseOutcome, type RemoteLeasedConnection, type RemoteOptions, type RemoteProtocol, type RemotePurpose, type RemoteReleaseInfo, type RemoteSupport, type RemoteTarget, RenderFailure, RenderResult, RenderRuns, RenderedSnapshot, type RunnerTarget, type StoryEntry, type StorySourceAdapter, TARGET_NAME_PATTERN, TargetLaunchError, type TargetOffers, type TargetReach, type TargetStatus, TestOutcome, type Viewport, connectLeased, createTargetOffers, defineRemote, engineOf, hasSchemeFor, isBrowserName, isCaptureTarget, isRemoteBrowser, onRemoteShutdown, parseTarget, refCounted, remoteTargetsOf, scrubConnection, shutdownRemote, tryParseTarget, unavailableReason };
|
|
212
|
+
export { BROWSER_NAMES, type BrowserName, CaptureEnvironment, CaptureRunner, type CaptureTarget, type ConnectLeasedContext, type ConnectLeasedOptions, DEFAULT_BROWSER, type HeldConnection, type JobStatus, type ParsedTarget, type PoolLease, type RefCounted, type RefCountedOptions, type RemoteBrowser, type RemoteConnectContext, RemoteConnectError, type RemoteConnectErrorOptions, type RemoteConnection, type RemoteLeaseOutcome, type RemoteLeasedConnection, type RemoteOptions, type RemoteProtocol, type RemotePurpose, type RemoteReleaseInfo, type RemoteSizing, type RemoteSupport, type RemoteTarget, RenderFailure, RenderResult, RenderRuns, RenderedSnapshot, type RunnerTarget, type StoryEntry, type StorySourceAdapter, TARGET_NAME_PATTERN, TargetLaunchError, type TargetOffers, type TargetReach, type TargetStatus, TestOutcome, UNVERIFIED_NOTE, type Viewport, connectLeased, createTargetOffers, defineRemote, engineOf, hasSchemeFor, isBrowserName, isCaptureTarget, isRemoteBrowser, logRemoteSizing, onRemoteShutdown, parseTarget, refCounted, remoteSizingOf, remoteTargetsOf, scrubConnection, shutdownRemote, tryParseTarget, unavailableReason };
|
|
212
213
|
//# sourceMappingURL=capture-runner.d.mts.map
|
|
@@ -7,6 +7,7 @@ import { defineRemote, hasSchemeFor, isRemoteBrowser, remoteTargetsOf, scrubConn
|
|
|
7
7
|
import { RemoteConnectError } from "../adapters/remote-lease-error.mjs";
|
|
8
8
|
import { onRemoteShutdown, shutdownRemote } from "../adapters/remote-lease-shutdown.mjs";
|
|
9
9
|
import { connectLeased } from "../adapters/remote-lease.mjs";
|
|
10
|
+
import { UNVERIFIED_NOTE, logRemoteSizing, remoteSizingOf } from "../adapters/remote-sizing.mjs";
|
|
10
11
|
import { refCounted } from "../adapters/remote-pool.mjs";
|
|
11
12
|
import { RenderRuns } from "../adapters/render-runs.mjs";
|
|
12
|
-
export { BROWSER_NAMES, DEFAULT_BROWSER, RemoteConnectError, RenderRuns, TARGET_NAME_PATTERN, TargetLaunchError, connectLeased, createTargetOffers, defineRemote, engineOf, hasSchemeFor, isBrowserName, isCaptureTarget, isRemoteBrowser, onRemoteShutdown, parseTarget, refCounted, remoteTargetsOf, scrubConnection, shutdownRemote, tryParseTarget, unavailableReason };
|
|
13
|
+
export { BROWSER_NAMES, DEFAULT_BROWSER, RemoteConnectError, RenderRuns, TARGET_NAME_PATTERN, TargetLaunchError, UNVERIFIED_NOTE, connectLeased, createTargetOffers, defineRemote, engineOf, hasSchemeFor, isBrowserName, isCaptureTarget, isRemoteBrowser, logRemoteSizing, onRemoteShutdown, parseTarget, refCounted, remoteSizingOf, remoteTargetsOf, scrubConnection, shutdownRemote, tryParseTarget, unavailableReason };
|
|
@@ -61,6 +61,12 @@ interface RemoteOptions<TLease = unknown> {
|
|
|
61
61
|
* cancel or shutdown ends the wait. Per process; the deployment-wide cap is another issue.
|
|
62
62
|
*/
|
|
63
63
|
maxConnections?: number | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Marks a vendor preset as experimental: written against the provider's documentation and not
|
|
66
|
+
* verified against live infrastructure. Reported in the run's sizing log and the docs; it changes
|
|
67
|
+
* no behavior.
|
|
68
|
+
*/
|
|
69
|
+
experimental?: boolean | undefined;
|
|
64
70
|
/** Declared, never inferred: how the runner talks to the service. */
|
|
65
71
|
protocol: RemoteProtocol;
|
|
66
72
|
/**
|
|
@@ -110,6 +116,8 @@ interface RemoteBrowser {
|
|
|
110
116
|
readonly sessions: number | (() => number);
|
|
111
117
|
readonly maxPagesPerConnection?: number | undefined;
|
|
112
118
|
readonly maxConnections?: number | undefined;
|
|
119
|
+
/** A vendor preset not verified against live infrastructure (see {@link RemoteOptions.experimental}). */
|
|
120
|
+
readonly experimental?: boolean | undefined;
|
|
113
121
|
readonly protocol: RemoteProtocol;
|
|
114
122
|
readonly reach: TargetReach;
|
|
115
123
|
readonly connect: RemoteOptions["connect"];
|
|
@@ -35,6 +35,7 @@ const KNOWN = /* @__PURE__ */ new Set([
|
|
|
35
35
|
"sessions",
|
|
36
36
|
"maxPagesPerConnection",
|
|
37
37
|
"maxConnections",
|
|
38
|
+
"experimental",
|
|
38
39
|
"protocol",
|
|
39
40
|
"reach",
|
|
40
41
|
"connect",
|
|
@@ -60,6 +61,7 @@ function defineRemote(options, support) {
|
|
|
60
61
|
sessions: options.sessions,
|
|
61
62
|
maxPagesPerConnection: options.maxPagesPerConnection,
|
|
62
63
|
maxConnections: options.maxConnections,
|
|
64
|
+
experimental: options.experimental === true,
|
|
63
65
|
protocol: options.protocol,
|
|
64
66
|
reach: options.reach,
|
|
65
67
|
connect: options.connect,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-browser.mjs","names":[],"sources":["../../src/adapters/remote-browser.ts"],"sourcesContent":["/**\n * Remote browsers (ADR 0007, decisions 1 and 3): one service a runner connects to instead of\n * launching a browser, expanding to one `<engine>@<name>` target per engine. Presets are plain\n * configuration over {@link defineRemote}; the runners add only what they can drive.\n */\nimport {\n TARGET_NAME_PATTERN,\n isBrowserName,\n type BrowserName,\n type CaptureTarget,\n type TargetReach,\n} from \"./capture-target.ts\";\n\n/** Default bound on measuring a connected remote browser's environment (`timeouts.probeMs`). */\nexport const DEFAULT_PROBE_MS = 15_000;\n\n/** How a runner talks to the service: Playwright's own protocol, or the Chrome DevTools Protocol. */\nexport type RemoteProtocol = \"playwright\" | \"cdp\";\n\n/** Where and how to connect. A function `connect` is called per connection, so a token can be fresh. */\nexport interface RemoteConnection {\n /** `ws(s)://…` (CDP also accepts `http(s)://…`). May carry credentials: it is never logged. */\n endpoint: string;\n /** Request headers for the handshake, such as `authorization`. Never logged. */\n headers?: Record<string, string>;\n}\n\n/** A connection a function `connect` returns, with what `release` needs back. */\nexport interface RemoteLeasedConnection<TLease = unknown> extends RemoteConnection {\n /** Opaque to the runner, handed to `release` once and never logged (it may carry credentials). */\n lease?: TLease | undefined;\n}\n\n/** What a run is doing when it connects. */\nexport type RemotePurpose = \"render\" | \"extract\" | \"tests\";\n\n/** How a leased connection ended, handed to `release`. */\nexport type RemoteLeaseOutcome = \"finished\" | \"failed\" | \"cancelled\";\n\n/** What a function `connect` is told about the connection it is asked for. */\nexport interface RemoteConnectContext {\n engine: BrowserName;\n /** The StoryShelf build; the same for every shard of it. */\n buildId: string;\n /** The shard's own id; the build id when the render is not sharded. */\n runId: string;\n /** The shard's 1-based position in the build's plan; `undefined` (never 0) when not sharded. */\n shardIndex?: number | undefined;\n /** `render` for a build or shard, `extract` for the parameter pre-pass, `tests` for `.test()` entries. */\n purpose: RemotePurpose;\n /** This connection's attempt, from 1; each retry connects anew. */\n attempt: number;\n /**\n * Aborts while `connect` is pending: on `timeouts.acquireMs`, a cancel (user, build, render\n * budget) and shutdown. Spent once `connect` resolves.\n */\n signal: AbortSignal;\n}\n\n/** What `release` is told about how the connection's render went. */\nexport interface RemoteReleaseInfo {\n outcome: RemoteLeaseOutcome;\n /** Why it failed, for `failed`. Carries no credentials, but may carry provider text. */\n error?: unknown;\n}\n\n/** What {@link defineRemote} takes: what `remote()` exposes to operators. */\nexport interface RemoteOptions<TLease = unknown> {\n /** The target name half (`chromium@<name>`): lowercase letters, digits and hyphens, at most 32. */\n name: string;\n /** The engines to offer (a subset of what the service has). */\n engines: readonly BrowserName[];\n /** Parallel sessions the plan allows: the page cap of the target, per process (decision 4). A function is read live. */\n sessions: number | (() => number);\n /** Maximum concurrent pages on each browser connection; independent of the shared session gate. */\n maxPagesPerConnection?: number | undefined;\n /**\n * Maximum connections this process holds to the service at once (default: no cap). A separate\n * limit from `sessions` (pages) and `maxPagesPerConnection`: a connection counts from the moment\n * it is requested until its render has ended, a shared one once. Renders over the cap wait, and a\n * cancel or shutdown ends the wait. Per process; the deployment-wide cap is another issue.\n */\n maxConnections?: number | undefined;\n /** Declared, never inferred: how the runner talks to the service. */\n protocol: RemoteProtocol;\n /**\n * Declared, never inferred: `loopback` when the remote browser can route back to this host\n * (Playwright's `exposeNetwork: \"<loopback>\"`), else `url` (it needs a signed Storybook URL).\n */\n reach: TargetReach;\n /**\n * The connection: an object for a fixed service (validated at creation), or a function called\n * for every connection (sync or async) to wake or start something, pick a server per shard, or\n * mint a token. A throw is retried and ends as a `TargetLaunchError`.\n */\n connect:\n | RemoteConnection\n | ((\n context: RemoteConnectContext,\n ) => RemoteLeasedConnection<TLease> | Promise<RemoteLeasedConnection<TLease>>);\n /**\n * Called once for every connection `connect` returned a `lease` for, after the render that used\n * it finished, failed or was cancelled (and for leases still held at shutdown, `cancelled`).\n * Only valid with a function `connect`. A throw or a timeout (`timeouts.releaseMs`) is logged and\n * never fails the render.\n */\n release?: ((lease: TLease, info: RemoteReleaseInfo) => Promise<void> | void) | undefined;\n /** OS the service renders on, reported when the page cannot tell (`linux-x64`, `windows`, …). */\n platform?: string | undefined;\n /** Connect retries: `attempts` after the first (default 3), `backoffMs` doubling (default 500). */\n retry?: { attempts?: number | undefined; backoffMs?: number | undefined } | undefined;\n /**\n * `acquireMs` bounds the `connect` function, `connectMs` the handshake after it (a farm with a\n * client cap queues extra connections, so a busy farm shows up as this timeout), `releaseMs`\n * the `release` hook. All default to 30 000. `probeMs` bounds measuring the browser's\n * environment once connected (default 15 000): a probe that overruns leaves the environment\n * unknown with a warning, and a cancel stops it at once.\n */\n timeouts?:\n | {\n acquireMs?: number | undefined;\n connectMs?: number | undefined;\n releaseMs?: number | undefined;\n probeMs?: number | undefined;\n }\n | undefined;\n}\n\n/** One remote service, validated: an entry of a runner's `browsers`. */\nexport interface RemoteBrowser {\n readonly kind: \"remote\";\n readonly name: string;\n readonly engines: readonly BrowserName[];\n readonly sessions: number | (() => number);\n readonly maxPagesPerConnection?: number | undefined;\n readonly maxConnections?: number | undefined;\n readonly protocol: RemoteProtocol;\n readonly reach: TargetReach;\n readonly connect: RemoteOptions[\"connect\"];\n readonly release: ((lease: never, info: RemoteReleaseInfo) => Promise<void> | void) | undefined;\n readonly platform: string | undefined;\n readonly retry: { readonly attempts: number; readonly backoffMs: number };\n readonly timeouts: {\n readonly acquireMs: number;\n readonly connectMs: number;\n readonly releaseMs: number;\n readonly probeMs: number;\n };\n}\n\n/** What a runner supports, which {@link defineRemote} checks the service against. */\nexport interface RemoteSupport {\n /** Engines the runner can drive. */\n engines: readonly BrowserName[];\n /** Protocols the runner can speak. */\n protocols: readonly RemoteProtocol[];\n /** Shown in errors, for example `remote()`. */\n label: string;\n}\n\n/** One target a remote service offers. */\nexport interface RemoteTarget {\n kind: \"remote\";\n target: CaptureTarget;\n engine: BrowserName;\n remote: RemoteBrowser;\n /** How the browser reaches the Storybook. */\n reach: TargetReach;\n}\n\nconst CDP_ENGINES = new Set<BrowserName>([\"chromium\", \"chrome\"]);\nconst SCHEMES: Record<RemoteProtocol, readonly string[]> = {\n playwright: [\"ws:\", \"wss:\"],\n cdp: [\"ws:\", \"wss:\", \"http:\", \"https:\"],\n};\n\n/** Options 0.7 took that 0.8 renamed or removed, with what replaces them (a loud migration error). */\nconst MOVED: Record<string, string> = {\n acquire: \"merge it into `connect` (a function that receives a context)\",\n exposeNetwork: 'declare `reach: \"loopback\"` on the service instead',\n connectRetries: \"use `retry: { attempts }`\",\n connectBackoffMs: \"use `retry: { backoffMs }`\",\n connectTimeoutMs: \"use `timeouts: { connectMs }`\",\n acquireTimeoutMs: \"use `timeouts: { acquireMs }`\",\n};\n\nconst KNOWN = new Set([\n \"name\",\n \"engines\",\n \"sessions\",\n \"maxPagesPerConnection\",\n \"maxConnections\",\n \"protocol\",\n \"reach\",\n \"connect\",\n \"release\",\n \"platform\",\n \"retry\",\n \"timeouts\",\n]);\n\n/**\n * Validate a remote service eagerly, so a mistake fails when the runner is created and not when the\n * first build runs: unknown or moved options, the name, the sessions, the engines against what the\n * runner drives, the protocol, the reach, and a fixed connection (endpoint scheme).\n */\nexport function defineRemote<TLease = unknown>(\n options: RemoteOptions<TLease>,\n support: RemoteSupport,\n): RemoteBrowser {\n const plain = options as unknown as RemoteOptions;\n checkOptions(plain, support);\n checkEngines(plain, support);\n checkProtocol(plain, support);\n return {\n kind: \"remote\",\n name: options.name,\n engines: [...options.engines],\n sessions: options.sessions,\n maxPagesPerConnection: options.maxPagesPerConnection,\n maxConnections: options.maxConnections,\n protocol: options.protocol,\n reach: options.reach,\n connect: options.connect,\n release: options.release,\n platform: options.platform,\n retry: { attempts: options.retry?.attempts ?? 3, backoffMs: options.retry?.backoffMs ?? 500 },\n timeouts: {\n acquireMs: options.timeouts?.acquireMs ?? 30_000,\n connectMs: options.timeouts?.connectMs ?? 30_000,\n releaseMs: options.timeouts?.releaseMs ?? 30_000,\n probeMs: options.timeouts?.probeMs ?? DEFAULT_PROBE_MS,\n },\n };\n}\n\n/** The targets a service offers, in the order of its `engines`. */\nexport function remoteTargetsOf(remote: RemoteBrowser): RemoteTarget[] {\n return remote.engines.map((engine) => ({\n kind: \"remote\",\n target: `${engine}@${remote.name}`,\n engine,\n remote,\n reach: remote.reach,\n }));\n}\n\n/** Whether a value is a remote service entry. */\nexport function isRemoteBrowser(value: unknown): value is RemoteBrowser {\n return typeof value === \"object\" && value !== null && (value as RemoteBrowser).kind === \"remote\";\n}\n\n/** Whether `endpoint` has a scheme `protocol` can open. */\nexport function hasSchemeFor(protocol: RemoteProtocol, endpoint: string): boolean {\n try {\n return SCHEMES[protocol].includes(new URL(endpoint).protocol);\n } catch {\n return false;\n }\n}\n\n/** The schemes `protocol` accepts, for messages. */\nexport function schemesOf(protocol: RemoteProtocol): string {\n return SCHEMES[protocol].join(\" or \");\n}\n\n/**\n * Remove what identifies the connection from text surfaced to operators: the endpoint (it can carry\n * a key in its path or query), its host and path, its query values, every header value and every\n * value inside the lease (non-strings are stringified).\n */\nexport function scrubConnection(\n text: string,\n connection: RemoteConnection & { lease?: unknown },\n): string {\n const secrets = [\n connection.endpoint,\n ...Object.values(connection.headers ?? {}),\n ...leaseSecrets(connection.lease),\n ];\n try {\n const url = new URL(connection.endpoint);\n // Drivers echo the endpoint without its query; the host and a path that acts as a key stay hidden.\n secrets.push(\n url.password,\n ...url.searchParams.values(),\n decodeURIComponent(url.search),\n `${url.protocol}//${url.host}${url.pathname}`,\n url.host,\n url.pathname,\n );\n } catch {\n // An unparsable endpoint is scrubbed whole above.\n }\n let scrubbed = text;\n // Longest first: the endpoint goes before the fragments it contains.\n for (const secret of secrets\n .filter((value) => value.length >= 4)\n .toSorted((a, b) => b.length - a.length)) {\n scrubbed = scrubbed.replaceAll(secret, \"[redacted]\");\n }\n return scrubbed;\n}\n\n/** Every value of a lease, as text: a string whole, anything else stringified, objects walked. */\nfunction leaseSecrets(lease: unknown, depth = 0): string[] {\n if (lease === undefined || lease === null) {\n return [];\n }\n if (typeof lease === \"object\") {\n return depth > 3 ? [] : Object.values(lease).flatMap((value) => leaseSecrets(value, depth + 1));\n }\n return typeof lease === \"string\" || typeof lease === \"number\" || typeof lease === \"boolean\"\n ? [String(lease)]\n : [];\n}\n\nfunction checkKnownKeys(options: RemoteOptions, where: string): void {\n for (const key of Object.keys(options)) {\n if (!KNOWN.has(key)) {\n const moved = MOVED[key];\n throw new Error(\n moved === undefined\n ? `${where}: unknown option \"${key}\"`\n : `${where}: option \"${key}\" is gone in 0.8: ${moved}`,\n );\n }\n }\n}\n\nfunction checkOptions(options: RemoteOptions, { label }: RemoteSupport): void {\n const where = `${label} \"${options.name}\"`;\n checkKnownKeys(options, where);\n if (!TARGET_NAME_PATTERN.test(options.name)) {\n throw new Error(\n `${label}: name \"${options.name}\" must be lowercase letters, digits and hyphens, start with a letter or digit and be at most 32 characters`,\n );\n }\n checkSessions(options.sessions, where);\n checkPositiveInteger(\"maxPagesPerConnection\", options.maxPagesPerConnection, where);\n checkPositiveInteger(\"maxConnections\", options.maxConnections, where);\n checkNumbers(options, where);\n if (options.release !== undefined && typeof options.connect !== \"function\") {\n throw new Error(`${where}: release needs a function connect (a fixed connection has no lease)`);\n }\n}\n\n/** An optional limit: absent, or a whole number of at least 1. */\nfunction checkPositiveInteger(name: string, value: number | undefined, where: string): void {\n if (value !== undefined && (!Number.isInteger(value) || value < 1)) {\n throw new Error(`${where}: ${name} must be a positive integer`);\n }\n}\n\nfunction checkSessions(sessions: RemoteOptions[\"sessions\"], where: string): void {\n const value = typeof sessions === \"function\" ? sessions() : sessions;\n if (!Number.isInteger(value) || value < 1) {\n throw new Error(`${where}: sessions must be a positive integer`);\n }\n}\n\nfunction checkNumbers(options: RemoteOptions, where: string): void {\n const entries: [string, number | undefined][] = [\n [\"retry.attempts\", options.retry?.attempts],\n [\"timeouts.acquireMs\", options.timeouts?.acquireMs],\n [\"timeouts.connectMs\", options.timeouts?.connectMs],\n [\"timeouts.releaseMs\", options.timeouts?.releaseMs],\n [\"timeouts.probeMs\", options.timeouts?.probeMs],\n [\"retry.backoffMs\", options.retry?.backoffMs],\n ];\n for (const [name, value] of entries) {\n const allowed = name === \"retry.attempts\" || name === \"retry.backoffMs\" ? 0 : 1;\n if (value !== undefined && (!Number.isInteger(value) || value < allowed)) {\n throw new Error(`${where}: ${name} must be an integer of at least ${allowed}`);\n }\n }\n}\n\nfunction checkEngines(options: RemoteOptions, support: RemoteSupport): void {\n const where = `${support.label} \"${options.name}\"`;\n if (options.engines.length === 0) {\n throw new Error(`${where}: engines must list at least one engine`);\n }\n for (const [index, engine] of options.engines.entries()) {\n if (!isBrowserName(engine)) {\n throw new Error(`${where}: unknown engine \"${String(engine)}\"`);\n }\n if (!support.engines.includes(engine)) {\n throw new Error(\n `${where}: this runner cannot drive \"${engine}\" (it drives ${support.engines.join(\", \")})`,\n );\n }\n if (options.engines.indexOf(engine) !== index) {\n throw new Error(`${where}: engine \"${engine}\" is listed twice`);\n }\n }\n}\n\nfunction checkProtocol(options: RemoteOptions, support: RemoteSupport): void {\n const where = `${support.label} \"${options.name}\"`;\n checkDeclared(options, support, where);\n if (options.protocol === \"cdp\") {\n checkCdp(options, where);\n }\n if (typeof options.connect !== \"function\") {\n checkFixedConnection(options.connect, options.protocol, where);\n }\n}\n\nfunction checkDeclared(options: RemoteOptions, support: RemoteSupport, where: string): void {\n if (options.protocol === undefined || options.reach === undefined) {\n throw new Error(\n `${where}: \"protocol\" and \"reach\" are required in 0.8 (declare how the runner talks to the service and whether its browser can reach this host)`,\n );\n }\n if (!support.protocols.includes(options.protocol)) {\n throw new Error(\n `${where}: protocol \"${options.protocol}\" is not supported by this runner (it speaks ${support.protocols.join(\", \")})`,\n );\n }\n if (options.reach !== \"loopback\" && options.reach !== \"url\") {\n throw new Error(`${where}: reach must be \"loopback\" or \"url\"`);\n }\n}\n\nfunction checkCdp(options: RemoteOptions, where: string): void {\n const bad = options.engines.find((engine) => !CDP_ENGINES.has(engine));\n if (bad !== undefined) {\n throw new Error(`${where}: the CDP protocol drives Chromium only, not \"${bad}\"`);\n }\n if (options.reach === \"loopback\") {\n throw new Error(`${where}: reach \"loopback\" needs the \"playwright\" protocol`);\n }\n}\n\nfunction checkFixedConnection(\n connection: RemoteConnection,\n protocol: RemoteProtocol,\n where: string,\n): void {\n const extra = Object.keys(connection).filter((key) => key !== \"endpoint\" && key !== \"headers\");\n if (extra.length > 0) {\n throw new Error(`${where}: connect has unknown field(s) ${extra.join(\", \")}`);\n }\n if (!hasSchemeFor(protocol, connection.endpoint)) {\n throw new Error(`${where}: the endpoint must be a ${schemesOf(protocol)} URL`);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"remote-browser.mjs","names":[],"sources":["../../src/adapters/remote-browser.ts"],"sourcesContent":["/**\n * Remote browsers (ADR 0007, decisions 1 and 3): one service a runner connects to instead of\n * launching a browser, expanding to one `<engine>@<name>` target per engine. Presets are plain\n * configuration over {@link defineRemote}; the runners add only what they can drive.\n */\nimport {\n TARGET_NAME_PATTERN,\n isBrowserName,\n type BrowserName,\n type CaptureTarget,\n type TargetReach,\n} from \"./capture-target.ts\";\n\n/** Default bound on measuring a connected remote browser's environment (`timeouts.probeMs`). */\nexport const DEFAULT_PROBE_MS = 15_000;\n\n/** How a runner talks to the service: Playwright's own protocol, or the Chrome DevTools Protocol. */\nexport type RemoteProtocol = \"playwright\" | \"cdp\";\n\n/** Where and how to connect. A function `connect` is called per connection, so a token can be fresh. */\nexport interface RemoteConnection {\n /** `ws(s)://…` (CDP also accepts `http(s)://…`). May carry credentials: it is never logged. */\n endpoint: string;\n /** Request headers for the handshake, such as `authorization`. Never logged. */\n headers?: Record<string, string>;\n}\n\n/** A connection a function `connect` returns, with what `release` needs back. */\nexport interface RemoteLeasedConnection<TLease = unknown> extends RemoteConnection {\n /** Opaque to the runner, handed to `release` once and never logged (it may carry credentials). */\n lease?: TLease | undefined;\n}\n\n/** What a run is doing when it connects. */\nexport type RemotePurpose = \"render\" | \"extract\" | \"tests\";\n\n/** How a leased connection ended, handed to `release`. */\nexport type RemoteLeaseOutcome = \"finished\" | \"failed\" | \"cancelled\";\n\n/** What a function `connect` is told about the connection it is asked for. */\nexport interface RemoteConnectContext {\n engine: BrowserName;\n /** The StoryShelf build; the same for every shard of it. */\n buildId: string;\n /** The shard's own id; the build id when the render is not sharded. */\n runId: string;\n /** The shard's 1-based position in the build's plan; `undefined` (never 0) when not sharded. */\n shardIndex?: number | undefined;\n /** `render` for a build or shard, `extract` for the parameter pre-pass, `tests` for `.test()` entries. */\n purpose: RemotePurpose;\n /** This connection's attempt, from 1; each retry connects anew. */\n attempt: number;\n /**\n * Aborts while `connect` is pending: on `timeouts.acquireMs`, a cancel (user, build, render\n * budget) and shutdown. Spent once `connect` resolves.\n */\n signal: AbortSignal;\n}\n\n/** What `release` is told about how the connection's render went. */\nexport interface RemoteReleaseInfo {\n outcome: RemoteLeaseOutcome;\n /** Why it failed, for `failed`. Carries no credentials, but may carry provider text. */\n error?: unknown;\n}\n\n/** What {@link defineRemote} takes: what `remote()` exposes to operators. */\nexport interface RemoteOptions<TLease = unknown> {\n /** The target name half (`chromium@<name>`): lowercase letters, digits and hyphens, at most 32. */\n name: string;\n /** The engines to offer (a subset of what the service has). */\n engines: readonly BrowserName[];\n /** Parallel sessions the plan allows: the page cap of the target, per process (decision 4). A function is read live. */\n sessions: number | (() => number);\n /** Maximum concurrent pages on each browser connection; independent of the shared session gate. */\n maxPagesPerConnection?: number | undefined;\n /**\n * Maximum connections this process holds to the service at once (default: no cap). A separate\n * limit from `sessions` (pages) and `maxPagesPerConnection`: a connection counts from the moment\n * it is requested until its render has ended, a shared one once. Renders over the cap wait, and a\n * cancel or shutdown ends the wait. Per process; the deployment-wide cap is another issue.\n */\n maxConnections?: number | undefined;\n /**\n * Marks a vendor preset as experimental: written against the provider's documentation and not\n * verified against live infrastructure. Reported in the run's sizing log and the docs; it changes\n * no behavior.\n */\n experimental?: boolean | undefined;\n /** Declared, never inferred: how the runner talks to the service. */\n protocol: RemoteProtocol;\n /**\n * Declared, never inferred: `loopback` when the remote browser can route back to this host\n * (Playwright's `exposeNetwork: \"<loopback>\"`), else `url` (it needs a signed Storybook URL).\n */\n reach: TargetReach;\n /**\n * The connection: an object for a fixed service (validated at creation), or a function called\n * for every connection (sync or async) to wake or start something, pick a server per shard, or\n * mint a token. A throw is retried and ends as a `TargetLaunchError`.\n */\n connect:\n | RemoteConnection\n | ((\n context: RemoteConnectContext,\n ) => RemoteLeasedConnection<TLease> | Promise<RemoteLeasedConnection<TLease>>);\n /**\n * Called once for every connection `connect` returned a `lease` for, after the render that used\n * it finished, failed or was cancelled (and for leases still held at shutdown, `cancelled`).\n * Only valid with a function `connect`. A throw or a timeout (`timeouts.releaseMs`) is logged and\n * never fails the render.\n */\n release?: ((lease: TLease, info: RemoteReleaseInfo) => Promise<void> | void) | undefined;\n /** OS the service renders on, reported when the page cannot tell (`linux-x64`, `windows`, …). */\n platform?: string | undefined;\n /** Connect retries: `attempts` after the first (default 3), `backoffMs` doubling (default 500). */\n retry?: { attempts?: number | undefined; backoffMs?: number | undefined } | undefined;\n /**\n * `acquireMs` bounds the `connect` function, `connectMs` the handshake after it (a farm with a\n * client cap queues extra connections, so a busy farm shows up as this timeout), `releaseMs`\n * the `release` hook. All default to 30 000. `probeMs` bounds measuring the browser's\n * environment once connected (default 15 000): a probe that overruns leaves the environment\n * unknown with a warning, and a cancel stops it at once.\n */\n timeouts?:\n | {\n acquireMs?: number | undefined;\n connectMs?: number | undefined;\n releaseMs?: number | undefined;\n probeMs?: number | undefined;\n }\n | undefined;\n}\n\n/** One remote service, validated: an entry of a runner's `browsers`. */\nexport interface RemoteBrowser {\n readonly kind: \"remote\";\n readonly name: string;\n readonly engines: readonly BrowserName[];\n readonly sessions: number | (() => number);\n readonly maxPagesPerConnection?: number | undefined;\n readonly maxConnections?: number | undefined;\n /** A vendor preset not verified against live infrastructure (see {@link RemoteOptions.experimental}). */\n readonly experimental?: boolean | undefined;\n readonly protocol: RemoteProtocol;\n readonly reach: TargetReach;\n readonly connect: RemoteOptions[\"connect\"];\n readonly release: ((lease: never, info: RemoteReleaseInfo) => Promise<void> | void) | undefined;\n readonly platform: string | undefined;\n readonly retry: { readonly attempts: number; readonly backoffMs: number };\n readonly timeouts: {\n readonly acquireMs: number;\n readonly connectMs: number;\n readonly releaseMs: number;\n readonly probeMs: number;\n };\n}\n\n/** What a runner supports, which {@link defineRemote} checks the service against. */\nexport interface RemoteSupport {\n /** Engines the runner can drive. */\n engines: readonly BrowserName[];\n /** Protocols the runner can speak. */\n protocols: readonly RemoteProtocol[];\n /** Shown in errors, for example `remote()`. */\n label: string;\n}\n\n/** One target a remote service offers. */\nexport interface RemoteTarget {\n kind: \"remote\";\n target: CaptureTarget;\n engine: BrowserName;\n remote: RemoteBrowser;\n /** How the browser reaches the Storybook. */\n reach: TargetReach;\n}\n\nconst CDP_ENGINES = new Set<BrowserName>([\"chromium\", \"chrome\"]);\nconst SCHEMES: Record<RemoteProtocol, readonly string[]> = {\n playwright: [\"ws:\", \"wss:\"],\n cdp: [\"ws:\", \"wss:\", \"http:\", \"https:\"],\n};\n\n/** Options 0.7 took that 0.8 renamed or removed, with what replaces them (a loud migration error). */\nconst MOVED: Record<string, string> = {\n acquire: \"merge it into `connect` (a function that receives a context)\",\n exposeNetwork: 'declare `reach: \"loopback\"` on the service instead',\n connectRetries: \"use `retry: { attempts }`\",\n connectBackoffMs: \"use `retry: { backoffMs }`\",\n connectTimeoutMs: \"use `timeouts: { connectMs }`\",\n acquireTimeoutMs: \"use `timeouts: { acquireMs }`\",\n};\n\nconst KNOWN = new Set([\n \"name\",\n \"engines\",\n \"sessions\",\n \"maxPagesPerConnection\",\n \"maxConnections\",\n \"experimental\",\n \"protocol\",\n \"reach\",\n \"connect\",\n \"release\",\n \"platform\",\n \"retry\",\n \"timeouts\",\n]);\n\n/**\n * Validate a remote service eagerly, so a mistake fails when the runner is created and not when the\n * first build runs: unknown or moved options, the name, the sessions, the engines against what the\n * runner drives, the protocol, the reach, and a fixed connection (endpoint scheme).\n */\nexport function defineRemote<TLease = unknown>(\n options: RemoteOptions<TLease>,\n support: RemoteSupport,\n): RemoteBrowser {\n const plain = options as unknown as RemoteOptions;\n checkOptions(plain, support);\n checkEngines(plain, support);\n checkProtocol(plain, support);\n return {\n kind: \"remote\",\n name: options.name,\n engines: [...options.engines],\n sessions: options.sessions,\n maxPagesPerConnection: options.maxPagesPerConnection,\n maxConnections: options.maxConnections,\n experimental: options.experimental === true,\n protocol: options.protocol,\n reach: options.reach,\n connect: options.connect,\n release: options.release,\n platform: options.platform,\n retry: { attempts: options.retry?.attempts ?? 3, backoffMs: options.retry?.backoffMs ?? 500 },\n timeouts: {\n acquireMs: options.timeouts?.acquireMs ?? 30_000,\n connectMs: options.timeouts?.connectMs ?? 30_000,\n releaseMs: options.timeouts?.releaseMs ?? 30_000,\n probeMs: options.timeouts?.probeMs ?? DEFAULT_PROBE_MS,\n },\n };\n}\n\n/** The targets a service offers, in the order of its `engines`. */\nexport function remoteTargetsOf(remote: RemoteBrowser): RemoteTarget[] {\n return remote.engines.map((engine) => ({\n kind: \"remote\",\n target: `${engine}@${remote.name}`,\n engine,\n remote,\n reach: remote.reach,\n }));\n}\n\n/** Whether a value is a remote service entry. */\nexport function isRemoteBrowser(value: unknown): value is RemoteBrowser {\n return typeof value === \"object\" && value !== null && (value as RemoteBrowser).kind === \"remote\";\n}\n\n/** Whether `endpoint` has a scheme `protocol` can open. */\nexport function hasSchemeFor(protocol: RemoteProtocol, endpoint: string): boolean {\n try {\n return SCHEMES[protocol].includes(new URL(endpoint).protocol);\n } catch {\n return false;\n }\n}\n\n/** The schemes `protocol` accepts, for messages. */\nexport function schemesOf(protocol: RemoteProtocol): string {\n return SCHEMES[protocol].join(\" or \");\n}\n\n/**\n * Remove what identifies the connection from text surfaced to operators: the endpoint (it can carry\n * a key in its path or query), its host and path, its query values, every header value and every\n * value inside the lease (non-strings are stringified).\n */\nexport function scrubConnection(\n text: string,\n connection: RemoteConnection & { lease?: unknown },\n): string {\n const secrets = [\n connection.endpoint,\n ...Object.values(connection.headers ?? {}),\n ...leaseSecrets(connection.lease),\n ];\n try {\n const url = new URL(connection.endpoint);\n // Drivers echo the endpoint without its query; the host and a path that acts as a key stay hidden.\n secrets.push(\n url.password,\n ...url.searchParams.values(),\n decodeURIComponent(url.search),\n `${url.protocol}//${url.host}${url.pathname}`,\n url.host,\n url.pathname,\n );\n } catch {\n // An unparsable endpoint is scrubbed whole above.\n }\n let scrubbed = text;\n // Longest first: the endpoint goes before the fragments it contains.\n for (const secret of secrets\n .filter((value) => value.length >= 4)\n .toSorted((a, b) => b.length - a.length)) {\n scrubbed = scrubbed.replaceAll(secret, \"[redacted]\");\n }\n return scrubbed;\n}\n\n/** Every value of a lease, as text: a string whole, anything else stringified, objects walked. */\nfunction leaseSecrets(lease: unknown, depth = 0): string[] {\n if (lease === undefined || lease === null) {\n return [];\n }\n if (typeof lease === \"object\") {\n return depth > 3 ? [] : Object.values(lease).flatMap((value) => leaseSecrets(value, depth + 1));\n }\n return typeof lease === \"string\" || typeof lease === \"number\" || typeof lease === \"boolean\"\n ? [String(lease)]\n : [];\n}\n\nfunction checkKnownKeys(options: RemoteOptions, where: string): void {\n for (const key of Object.keys(options)) {\n if (!KNOWN.has(key)) {\n const moved = MOVED[key];\n throw new Error(\n moved === undefined\n ? `${where}: unknown option \"${key}\"`\n : `${where}: option \"${key}\" is gone in 0.8: ${moved}`,\n );\n }\n }\n}\n\nfunction checkOptions(options: RemoteOptions, { label }: RemoteSupport): void {\n const where = `${label} \"${options.name}\"`;\n checkKnownKeys(options, where);\n if (!TARGET_NAME_PATTERN.test(options.name)) {\n throw new Error(\n `${label}: name \"${options.name}\" must be lowercase letters, digits and hyphens, start with a letter or digit and be at most 32 characters`,\n );\n }\n checkSessions(options.sessions, where);\n checkPositiveInteger(\"maxPagesPerConnection\", options.maxPagesPerConnection, where);\n checkPositiveInteger(\"maxConnections\", options.maxConnections, where);\n checkNumbers(options, where);\n if (options.release !== undefined && typeof options.connect !== \"function\") {\n throw new Error(`${where}: release needs a function connect (a fixed connection has no lease)`);\n }\n}\n\n/** An optional limit: absent, or a whole number of at least 1. */\nfunction checkPositiveInteger(name: string, value: number | undefined, where: string): void {\n if (value !== undefined && (!Number.isInteger(value) || value < 1)) {\n throw new Error(`${where}: ${name} must be a positive integer`);\n }\n}\n\nfunction checkSessions(sessions: RemoteOptions[\"sessions\"], where: string): void {\n const value = typeof sessions === \"function\" ? sessions() : sessions;\n if (!Number.isInteger(value) || value < 1) {\n throw new Error(`${where}: sessions must be a positive integer`);\n }\n}\n\nfunction checkNumbers(options: RemoteOptions, where: string): void {\n const entries: [string, number | undefined][] = [\n [\"retry.attempts\", options.retry?.attempts],\n [\"timeouts.acquireMs\", options.timeouts?.acquireMs],\n [\"timeouts.connectMs\", options.timeouts?.connectMs],\n [\"timeouts.releaseMs\", options.timeouts?.releaseMs],\n [\"timeouts.probeMs\", options.timeouts?.probeMs],\n [\"retry.backoffMs\", options.retry?.backoffMs],\n ];\n for (const [name, value] of entries) {\n const allowed = name === \"retry.attempts\" || name === \"retry.backoffMs\" ? 0 : 1;\n if (value !== undefined && (!Number.isInteger(value) || value < allowed)) {\n throw new Error(`${where}: ${name} must be an integer of at least ${allowed}`);\n }\n }\n}\n\nfunction checkEngines(options: RemoteOptions, support: RemoteSupport): void {\n const where = `${support.label} \"${options.name}\"`;\n if (options.engines.length === 0) {\n throw new Error(`${where}: engines must list at least one engine`);\n }\n for (const [index, engine] of options.engines.entries()) {\n if (!isBrowserName(engine)) {\n throw new Error(`${where}: unknown engine \"${String(engine)}\"`);\n }\n if (!support.engines.includes(engine)) {\n throw new Error(\n `${where}: this runner cannot drive \"${engine}\" (it drives ${support.engines.join(\", \")})`,\n );\n }\n if (options.engines.indexOf(engine) !== index) {\n throw new Error(`${where}: engine \"${engine}\" is listed twice`);\n }\n }\n}\n\nfunction checkProtocol(options: RemoteOptions, support: RemoteSupport): void {\n const where = `${support.label} \"${options.name}\"`;\n checkDeclared(options, support, where);\n if (options.protocol === \"cdp\") {\n checkCdp(options, where);\n }\n if (typeof options.connect !== \"function\") {\n checkFixedConnection(options.connect, options.protocol, where);\n }\n}\n\nfunction checkDeclared(options: RemoteOptions, support: RemoteSupport, where: string): void {\n if (options.protocol === undefined || options.reach === undefined) {\n throw new Error(\n `${where}: \"protocol\" and \"reach\" are required in 0.8 (declare how the runner talks to the service and whether its browser can reach this host)`,\n );\n }\n if (!support.protocols.includes(options.protocol)) {\n throw new Error(\n `${where}: protocol \"${options.protocol}\" is not supported by this runner (it speaks ${support.protocols.join(\", \")})`,\n );\n }\n if (options.reach !== \"loopback\" && options.reach !== \"url\") {\n throw new Error(`${where}: reach must be \"loopback\" or \"url\"`);\n }\n}\n\nfunction checkCdp(options: RemoteOptions, where: string): void {\n const bad = options.engines.find((engine) => !CDP_ENGINES.has(engine));\n if (bad !== undefined) {\n throw new Error(`${where}: the CDP protocol drives Chromium only, not \"${bad}\"`);\n }\n if (options.reach === \"loopback\") {\n throw new Error(`${where}: reach \"loopback\" needs the \"playwright\" protocol`);\n }\n}\n\nfunction checkFixedConnection(\n connection: RemoteConnection,\n protocol: RemoteProtocol,\n where: string,\n): void {\n const extra = Object.keys(connection).filter((key) => key !== \"endpoint\" && key !== \"headers\");\n if (extra.length > 0) {\n throw new Error(`${where}: connect has unknown field(s) ${extra.join(\", \")}`);\n }\n if (!hasSchemeFor(protocol, connection.endpoint)) {\n throw new Error(`${where}: the endpoint must be a ${schemesOf(protocol)} URL`);\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import __tsdown_shims_path from 'node:path';
|
|
2
|
+
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { CaptureTarget } from "./capture-target.mjs";
|
|
4
|
+
import { RemoteBrowser } from "./remote-browser.mjs";
|
|
5
|
+
/** Said of every experimental preset until someone verifies it against the provider. */
|
|
6
|
+
declare const UNVERIFIED_NOTE = "not verified against live infrastructure";
|
|
7
|
+
/** The loggable limits of one remote target. A `null` limit is not capped. */
|
|
8
|
+
interface RemoteSizing {
|
|
9
|
+
target: CaptureTarget;
|
|
10
|
+
/** Pages the plan allows at once: the target's page gate. Per target, per process. */
|
|
11
|
+
pages: number | null;
|
|
12
|
+
/** Pages on one connection, when capped. */
|
|
13
|
+
pagesPerConnection: number | null;
|
|
14
|
+
/** Pages one connection can hold: the lowest of `pages`, `pagesPerConnection` (when the runner enforces it) and the run's `concurrency`. */
|
|
15
|
+
effectivePagesPerConnection: number | null;
|
|
16
|
+
/** Connections this process holds at once, when capped. Per process. */
|
|
17
|
+
maxConnections: number | null;
|
|
18
|
+
scope: {
|
|
19
|
+
pages: "target";
|
|
20
|
+
connections: "process";
|
|
21
|
+
};
|
|
22
|
+
experimental: boolean;
|
|
23
|
+
/** Present only for an experimental preset. */
|
|
24
|
+
verification?: typeof UNVERIFIED_NOTE;
|
|
25
|
+
}
|
|
26
|
+
/** What the runner actually enforces, so the effective limit is not overstated. */
|
|
27
|
+
interface RemoteSizingEnforced {
|
|
28
|
+
/** The shared page gate of the target; its size is the effective page limit. */
|
|
29
|
+
gate?: {
|
|
30
|
+
readonly size: number;
|
|
31
|
+
} | undefined;
|
|
32
|
+
/** The run's configured page concurrency, when set. */
|
|
33
|
+
concurrency?: number | undefined;
|
|
34
|
+
/** Whether the runner applies `maxPagesPerConnection` (default true). */
|
|
35
|
+
pagesPerConnection?: boolean | undefined;
|
|
36
|
+
}
|
|
37
|
+
/** The configured and effective limits of `remote`, as far as the runner enforces them. */
|
|
38
|
+
declare function remoteSizingOf(target: CaptureTarget, remote: RemoteBrowser, enforced?: RemoteSizingEnforced): RemoteSizing;
|
|
39
|
+
/** Log the limits at run start, before any connection is attempted. */
|
|
40
|
+
declare function logRemoteSizing(logger: {
|
|
41
|
+
info(fields: object, message: string): void;
|
|
42
|
+
} | undefined, target: CaptureTarget, remote: RemoteBrowser, enforced?: RemoteSizingEnforced): void;
|
|
43
|
+
export { RemoteSizing, RemoteSizingEnforced, UNVERIFIED_NOTE, logRemoteSizing, remoteSizingOf };
|
|
44
|
+
//# sourceMappingURL=remote-sizing.d.mts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
// src/adapters/remote-sizing.ts
|
|
5
|
+
/** Said of every experimental preset until someone verifies it against the provider. */
|
|
6
|
+
const UNVERIFIED_NOTE = "not verified against live infrastructure";
|
|
7
|
+
/** The configured and effective limits of `remote`, as far as the runner enforces them. */
|
|
8
|
+
function remoteSizingOf(target, remote, enforced = {}) {
|
|
9
|
+
const pages = enforced.gate === void 0 ? pagesOf(remote) : gatePagesOf(enforced.gate);
|
|
10
|
+
const perConnection = remote.maxPagesPerConnection ?? null;
|
|
11
|
+
const limits = [
|
|
12
|
+
pages,
|
|
13
|
+
enforced.pagesPerConnection === false ? null : perConnection,
|
|
14
|
+
enforced.concurrency ?? null
|
|
15
|
+
].filter((value) => value !== null);
|
|
16
|
+
return {
|
|
17
|
+
target,
|
|
18
|
+
pages,
|
|
19
|
+
pagesPerConnection: perConnection,
|
|
20
|
+
effectivePagesPerConnection: limits.length === 0 ? null : Math.min(...limits),
|
|
21
|
+
maxConnections: remote.maxConnections ?? null,
|
|
22
|
+
scope: {
|
|
23
|
+
pages: "target",
|
|
24
|
+
connections: "process"
|
|
25
|
+
},
|
|
26
|
+
experimental: remote.experimental === true,
|
|
27
|
+
...remote.experimental === true ? { verification: UNVERIFIED_NOTE } : {}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Log the limits at run start, before any connection is attempted. */
|
|
31
|
+
function logRemoteSizing(logger, target, remote, enforced) {
|
|
32
|
+
logger?.info(remoteSizingOf(target, remote, enforced), "remote sizing");
|
|
33
|
+
}
|
|
34
|
+
/** The shared gate's cap; a `sessions` callback that throws reads as unknown. */
|
|
35
|
+
function gatePagesOf(gate) {
|
|
36
|
+
try {
|
|
37
|
+
return gate.size;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The page limit now; a `sessions` function that throws or returns nonsense reads as unknown. */
|
|
43
|
+
function pagesOf(remote) {
|
|
44
|
+
try {
|
|
45
|
+
const value = typeof remote.sessions === "function" ? remote.sessions() : remote.sessions;
|
|
46
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export { UNVERIFIED_NOTE, logRemoteSizing, remoteSizingOf };
|
|
52
|
+
|
|
53
|
+
//# sourceMappingURL=remote-sizing.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote-sizing.mjs","names":[],"sources":["../../src/adapters/remote-sizing.ts"],"sourcesContent":["/** What a remote target may open, for the run's log (ADR 0007, decision 4): limits only, no endpoints. */\nimport type { CaptureTarget } from \"./capture-target.ts\";\nimport type { RemoteBrowser } from \"./remote-browser.ts\";\n\n/** Said of every experimental preset until someone verifies it against the provider. */\nexport const UNVERIFIED_NOTE = \"not verified against live infrastructure\";\n\n/** The loggable limits of one remote target. A `null` limit is not capped. */\nexport interface RemoteSizing {\n target: CaptureTarget;\n /** Pages the plan allows at once: the target's page gate. Per target, per process. */\n pages: number | null;\n /** Pages on one connection, when capped. */\n pagesPerConnection: number | null;\n /** Pages one connection can hold: the lowest of `pages`, `pagesPerConnection` (when the runner enforces it) and the run's `concurrency`. */\n effectivePagesPerConnection: number | null;\n /** Connections this process holds at once, when capped. Per process. */\n maxConnections: number | null;\n scope: { pages: \"target\"; connections: \"process\" };\n experimental: boolean;\n /** Present only for an experimental preset. */\n verification?: typeof UNVERIFIED_NOTE;\n}\n\n/** What the runner actually enforces, so the effective limit is not overstated. */\nexport interface RemoteSizingEnforced {\n /** The shared page gate of the target; its size is the effective page limit. */\n gate?: { readonly size: number } | undefined;\n /** The run's configured page concurrency, when set. */\n concurrency?: number | undefined;\n /** Whether the runner applies `maxPagesPerConnection` (default true). */\n pagesPerConnection?: boolean | undefined;\n}\n\n/** The configured and effective limits of `remote`, as far as the runner enforces them. */\nexport function remoteSizingOf(\n target: CaptureTarget,\n remote: RemoteBrowser,\n enforced: RemoteSizingEnforced = {},\n): RemoteSizing {\n const pages = enforced.gate === undefined ? pagesOf(remote) : gatePagesOf(enforced.gate);\n const perConnection = remote.maxPagesPerConnection ?? null;\n const limits = [\n pages,\n enforced.pagesPerConnection === false ? null : perConnection,\n enforced.concurrency ?? null,\n ].filter((value): value is number => value !== null);\n return {\n target,\n pages,\n pagesPerConnection: perConnection,\n effectivePagesPerConnection: limits.length === 0 ? null : Math.min(...limits),\n maxConnections: remote.maxConnections ?? null,\n scope: { pages: \"target\", connections: \"process\" },\n experimental: remote.experimental === true,\n ...(remote.experimental === true ? { verification: UNVERIFIED_NOTE } : {}),\n };\n}\n\n/** Log the limits at run start, before any connection is attempted. */\nexport function logRemoteSizing(\n logger: { info(fields: object, message: string): void } | undefined,\n target: CaptureTarget,\n remote: RemoteBrowser,\n enforced?: RemoteSizingEnforced,\n): void {\n logger?.info(remoteSizingOf(target, remote, enforced), \"remote sizing\");\n}\n\n/** The shared gate's cap; a `sessions` callback that throws reads as unknown. */\nfunction gatePagesOf(gate: { readonly size: number }): number | null {\n try {\n return gate.size;\n } catch {\n return null;\n }\n}\n\n/** The page limit now; a `sessions` function that throws or returns nonsense reads as unknown. */\nfunction pagesOf(remote: RemoteBrowser): number | null {\n try {\n const value = typeof remote.sessions === \"function\" ? remote.sessions() : remote.sessions;\n return Number.isInteger(value) && value > 0 ? value : null;\n } catch {\n return null;\n }\n}\n"],"mappings":";;;;;AAKA,MAAa,kBAAkB;;AA8B/B,SAAgB,eACd,QACA,QACA,WAAiC,CAAC,GACpB;CACd,MAAM,QAAQ,SAAS,SAAS,KAAA,IAAY,QAAQ,MAAM,IAAI,YAAY,SAAS,IAAI;CACvF,MAAM,gBAAgB,OAAO,yBAAyB;CACtD,MAAM,SAAS;EACb;EACA,SAAS,uBAAuB,QAAQ,OAAO;EAC/C,SAAS,eAAe;CAC1B,CAAC,CAAC,QAAQ,UAA2B,UAAU,IAAI;CACnD,OAAO;EACL;EACA;EACA,oBAAoB;EACpB,6BAA6B,OAAO,WAAW,IAAI,OAAO,KAAK,IAAI,GAAG,MAAM;EAC5E,gBAAgB,OAAO,kBAAkB;EACzC,OAAO;GAAE,OAAO;GAAU,aAAa;EAAU;EACjD,cAAc,OAAO,iBAAiB;EACtC,GAAI,OAAO,iBAAiB,OAAO,EAAE,cAAc,gBAAgB,IAAI,CAAC;CAC1E;AACF;;AAGA,SAAgB,gBACd,QACA,QACA,QACA,UACM;CACN,QAAQ,KAAK,eAAe,QAAQ,QAAQ,QAAQ,GAAG,eAAe;AACxE;;AAGA,SAAS,YAAY,MAAgD;CACnE,IAAI;EACF,OAAO,KAAK;CACd,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAS,QAAQ,QAAsC;CACrD,IAAI;EACF,MAAM,QAAQ,OAAO,OAAO,aAAa,aAAa,OAAO,SAAS,IAAI,OAAO;EACjF,OAAO,OAAO,UAAU,KAAK,KAAK,QAAQ,IAAI,QAAQ;CACxD,QAAQ;EACN,OAAO;CACT;AACF"}
|
|
@@ -24,7 +24,7 @@ import { hostname } from "node:os";
|
|
|
24
24
|
/** What the database queue reports about itself; hosts show it before the queue is built. */
|
|
25
25
|
const DATABASE_QUEUE_METADATA = {
|
|
26
26
|
name: "Database Queue",
|
|
27
|
-
version: "0.
|
|
27
|
+
version: "0.9.0-next-d2cc082",
|
|
28
28
|
description: "Capture queue on the app database (capture_jobs)",
|
|
29
29
|
kind: "database",
|
|
30
30
|
category: "capture-queue"
|
|
@@ -3,8 +3,18 @@ import __tsdown_shims_url from 'node:url';
|
|
|
3
3
|
import { Viewport } from "./adapter.mjs";
|
|
4
4
|
import { CaptureEnvironment } from "../adapter/capture-runner.mjs";
|
|
5
5
|
import { SIZING_DEFAULTS } from "./sizing.mjs";
|
|
6
|
+
/** The components a comparison reads: the environment fields and the screenshot sizing. */
|
|
7
|
+
type MeasuredField = "engine" | "browserVersion" | "platform" | "deviceScaleFactor" | "fontsHash" | "sizing";
|
|
8
|
+
/** Components whose measurement changed, by the version that changed it. Tests pass their own. */
|
|
9
|
+
type MeasurementHistory = Readonly<Record<number, readonly MeasuredField[]>>;
|
|
6
10
|
/** The components behind a snapshot's `infraHash`, kept so a mismatch can say what changed. */
|
|
7
11
|
interface Fingerprint {
|
|
12
|
+
/**
|
|
13
|
+
* How the environment components were measured (see {@link MEASUREMENT_VERSION}). Absent means
|
|
14
|
+
* version 1. It is not part of `infraHash`: the baseline's identity must not change when only
|
|
15
|
+
* the measurement does.
|
|
16
|
+
*/
|
|
17
|
+
measurementVersion?: number | undefined;
|
|
8
18
|
engine: string;
|
|
9
19
|
browserVersion: string | null;
|
|
10
20
|
platform: string | null;
|
|
@@ -18,23 +28,26 @@ interface Fingerprint {
|
|
|
18
28
|
}
|
|
19
29
|
/** Build the fingerprint of one capture. Missing environment fields stay `null` (runner did not report them). */
|
|
20
30
|
declare function buildFingerprint(engine: string, viewport: Viewport, environment: CaptureEnvironment | undefined): Fingerprint;
|
|
21
|
-
/** Stable hash of a fingerprint: the value stored as `infraHash`. */
|
|
31
|
+
/** Stable hash of a fingerprint: the value stored as `infraHash`. `measurementVersion` is left out on purpose. */
|
|
22
32
|
declare function fingerprintHash(fingerprint: Fingerprint): string;
|
|
23
33
|
/**
|
|
24
34
|
* Names of the environment components that differ; `["unknown"]` when the earlier fingerprint was
|
|
25
35
|
* not recorded. A component one side did not report (`null`: a runner that does not report it, a
|
|
26
|
-
* capture spooled before a browser crash, a failed `fc-list`) is unknown, not changed
|
|
27
|
-
*
|
|
28
|
-
*
|
|
36
|
+
* capture spooled before a browser crash, a failed `fc-list`) is unknown, not changed, and so is a
|
|
37
|
+
* component measured differently in the two measurement versions. Viewport is never a change
|
|
38
|
+
* here: its size comes from story parameters (the PR's own code), so a new size must be reviewed,
|
|
39
|
+
* and it diffs on its own as a dimension change.
|
|
29
40
|
*/
|
|
30
|
-
declare function changedFields(from: Fingerprint | null, to: Fingerprint): string[];
|
|
41
|
+
declare function changedFields(from: Fingerprint | null, to: Fingerprint, history?: MeasurementHistory): string[];
|
|
31
42
|
/** Parse a stored fingerprint; null for a missing or malformed value. */
|
|
32
43
|
declare function parseFingerprint(raw: string | null | undefined): Fingerprint | null;
|
|
33
44
|
/** What a migrated snapshot records: which components changed, and the before and after. */
|
|
34
45
|
interface EnvironmentChange {
|
|
35
46
|
changed: string[];
|
|
47
|
+
/** Components not compared because their measurement changed between versions (absent: none). */
|
|
48
|
+
unknown?: string[] | undefined;
|
|
36
49
|
from: Fingerprint | null;
|
|
37
50
|
to: Fingerprint;
|
|
38
51
|
}
|
|
39
|
-
export { EnvironmentChange, Fingerprint, buildFingerprint, changedFields, fingerprintHash, parseFingerprint };
|
|
52
|
+
export { EnvironmentChange, Fingerprint, MeasuredField, MeasurementHistory, buildFingerprint, changedFields, fingerprintHash, parseFingerprint };
|
|
40
53
|
//# sourceMappingURL=fingerprint.d.mts.map
|
|
@@ -3,10 +3,16 @@ import "node:url";
|
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { sha256 } from "../utils/hash.mjs";
|
|
5
5
|
import { SIZING_DEFAULTS } from "./sizing.mjs";
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Components whose measurement changed when a version was introduced, by that version. Version 1
|
|
8
|
+
* is the baseline of the scheme, so nothing is listed yet. Between two versions, a listed
|
|
9
|
+
* component is compared as unknown, never as a mismatch (it must not cause an environment migration).
|
|
10
|
+
*/
|
|
11
|
+
const REMEASURED_IN = {};
|
|
7
12
|
/** Build the fingerprint of one capture. Missing environment fields stay `null` (runner did not report them). */
|
|
8
13
|
function buildFingerprint(engine, viewport, environment) {
|
|
9
14
|
return {
|
|
15
|
+
measurementVersion: 1,
|
|
10
16
|
engine,
|
|
11
17
|
browserVersion: environment?.browserVersion ?? null,
|
|
12
18
|
platform: environment?.platform ?? null,
|
|
@@ -19,7 +25,7 @@ function buildFingerprint(engine, viewport, environment) {
|
|
|
19
25
|
sizing: SIZING_DEFAULTS
|
|
20
26
|
};
|
|
21
27
|
}
|
|
22
|
-
/** Stable hash of a fingerprint: the value stored as `infraHash`. */
|
|
28
|
+
/** Stable hash of a fingerprint: the value stored as `infraHash`. `measurementVersion` is left out on purpose. */
|
|
23
29
|
function fingerprintHash(fingerprint) {
|
|
24
30
|
return sha256(JSON.stringify([
|
|
25
31
|
fingerprint.engine,
|
|
@@ -40,16 +46,38 @@ const ENVIRONMENT_FIELDS = [
|
|
|
40
46
|
"deviceScaleFactor",
|
|
41
47
|
"fontsHash"
|
|
42
48
|
];
|
|
49
|
+
/** The version a fingerprint was measured with; one stored before versions existed is version 1. */
|
|
50
|
+
function measurementVersionOf(fingerprint) {
|
|
51
|
+
return fingerprint.measurementVersion ?? 1;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Components measured differently in the two fingerprints: those listed for any version between
|
|
55
|
+
* theirs. Empty when the versions match (nothing about a same-version comparison changes).
|
|
56
|
+
*/
|
|
57
|
+
function remeasuredFields(from, to, history = REMEASURED_IN) {
|
|
58
|
+
const [low, high] = [measurementVersionOf(from), measurementVersionOf(to)].toSorted((left, right) => left - right);
|
|
59
|
+
const fields = Object.entries(history).filter(([version]) => Number(version) > (low ?? 1) && Number(version) <= (high ?? 1)).flatMap(([, listed]) => listed);
|
|
60
|
+
return [...new Set(fields)];
|
|
61
|
+
}
|
|
43
62
|
/**
|
|
44
63
|
* Names of the environment components that differ; `["unknown"]` when the earlier fingerprint was
|
|
45
64
|
* not recorded. A component one side did not report (`null`: a runner that does not report it, a
|
|
46
|
-
* capture spooled before a browser crash, a failed `fc-list`) is unknown, not changed
|
|
47
|
-
*
|
|
48
|
-
*
|
|
65
|
+
* capture spooled before a browser crash, a failed `fc-list`) is unknown, not changed, and so is a
|
|
66
|
+
* component measured differently in the two measurement versions. Viewport is never a change
|
|
67
|
+
* here: its size comes from story parameters (the PR's own code), so a new size must be reviewed,
|
|
68
|
+
* and it diffs on its own as a dimension change.
|
|
49
69
|
*/
|
|
50
|
-
function changedFields(from, to) {
|
|
70
|
+
function changedFields(from, to, history = REMEASURED_IN) {
|
|
51
71
|
if (!from) return ["unknown"];
|
|
52
|
-
|
|
72
|
+
const remeasured = new Set(remeasuredFields(from, to, history));
|
|
73
|
+
return [...ENVIRONMENT_FIELDS.filter((key) => !remeasured.has(key) && known(from[key]) && known(to[key]) && from[key] !== to[key]), ...remeasured.has("sizing") || JSON.stringify(from.sizing) === JSON.stringify(to.sizing) ? [] : ["sizing"]];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Components that could not be compared because their measurement changed between the two
|
|
77
|
+
* fingerprints' versions. The review says the environment is partly unknown; nothing migrates.
|
|
78
|
+
*/
|
|
79
|
+
function unknownFields(from, to, history = REMEASURED_IN) {
|
|
80
|
+
return from ? remeasuredFields(from, to, history) : [];
|
|
53
81
|
}
|
|
54
82
|
function known(value) {
|
|
55
83
|
return value !== null && value !== void 0;
|
|
@@ -58,13 +86,15 @@ function known(value) {
|
|
|
58
86
|
* The fingerprint to store on a baseline: the capture's, with any component it did not report kept
|
|
59
87
|
* from the baseline's earlier fingerprint, so an unknown component never overwrites a known one.
|
|
60
88
|
*/
|
|
61
|
-
function mergeFingerprint(earlier, now) {
|
|
89
|
+
function mergeFingerprint(earlier, now, history = REMEASURED_IN) {
|
|
90
|
+
const stale = new Set(earlier ? remeasuredFields(earlier, now, history) : []);
|
|
91
|
+
const kept = (key) => now[key] ?? (stale.has(key) ? null : earlier?.[key] ?? null);
|
|
62
92
|
return {
|
|
63
93
|
...now,
|
|
64
|
-
browserVersion:
|
|
65
|
-
platform:
|
|
66
|
-
deviceScaleFactor:
|
|
67
|
-
fontsHash:
|
|
94
|
+
browserVersion: kept("browserVersion"),
|
|
95
|
+
platform: kept("platform"),
|
|
96
|
+
deviceScaleFactor: kept("deviceScaleFactor"),
|
|
97
|
+
fontsHash: kept("fontsHash")
|
|
68
98
|
};
|
|
69
99
|
}
|
|
70
100
|
/** Parse a stored fingerprint; null for a missing or malformed value. */
|
|
@@ -77,6 +107,6 @@ function parseFingerprint(raw) {
|
|
|
77
107
|
return null;
|
|
78
108
|
}
|
|
79
109
|
}
|
|
80
|
-
export { buildFingerprint, changedFields, fingerprintHash, mergeFingerprint, parseFingerprint };
|
|
110
|
+
export { REMEASURED_IN, buildFingerprint, changedFields, fingerprintHash, measurementVersionOf, mergeFingerprint, parseFingerprint, remeasuredFields, unknownFields };
|
|
81
111
|
|
|
82
112
|
//# sourceMappingURL=fingerprint.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fingerprint.mjs","names":[],"sources":["../../src/capture/fingerprint.ts"],"sourcesContent":["/** Capture fingerprint: everything that changes pixels without a code change (ADR 0005 phase 4). */\nimport type { CaptureEnvironment } from \"../adapters/capture-runner.ts\";\nimport { sha256 } from \"../utils/hash.ts\";\nimport type { Viewport } from \"./adapter.ts\";\nimport { SIZING_DEFAULTS } from \"./sizing.ts\";\n\n/** The components behind a snapshot's `infraHash`, kept so a mismatch can say what changed. */\nexport interface Fingerprint {\n engine: string;\n browserVersion: string | null;\n platform: string | null;\n deviceScaleFactor: number | null;\n fontsHash: string | null;\n viewport: { width: number; height: number };\n sizing: typeof SIZING_DEFAULTS;\n}\n\n/** Build the fingerprint of one capture. Missing environment fields stay `null` (runner did not report them). */\nexport function buildFingerprint(\n engine: string,\n viewport: Viewport,\n environment: CaptureEnvironment | undefined,\n): Fingerprint {\n return {\n engine,\n browserVersion: environment?.browserVersion ?? null,\n platform: environment?.platform ?? null,\n deviceScaleFactor: environment?.deviceScaleFactor ?? null,\n fontsHash: environment?.fontsHash ?? null,\n viewport: { width: viewport.width, height: viewport.height },\n sizing: SIZING_DEFAULTS,\n };\n}\n\n/** Stable hash of a fingerprint: the value stored as `infraHash`. */\nexport function fingerprintHash(fingerprint: Fingerprint): string {\n return sha256(\n JSON.stringify([\n fingerprint.engine,\n fingerprint.browserVersion,\n fingerprint.platform,\n fingerprint.deviceScaleFactor,\n fingerprint.fontsHash,\n fingerprint.viewport.width,\n fingerprint.viewport.height,\n fingerprint.sizing,\n ]),\n );\n}\n\n/** The components whose change is an environment migration. Viewport is not one (see below). */\nconst ENVIRONMENT_FIELDS = [\n \"engine\",\n \"browserVersion\",\n \"platform\",\n \"deviceScaleFactor\",\n \"fontsHash\",\n] as const;\n\n/**\n * Names of the environment components that differ; `[\"unknown\"]` when the earlier fingerprint was\n * not recorded. A component one side did not report (`null`: a runner that does not report it, a\n * capture spooled before a browser crash, a failed `fc-list`) is unknown, not changed
|
|
1
|
+
{"version":3,"file":"fingerprint.mjs","names":[],"sources":["../../src/capture/fingerprint.ts"],"sourcesContent":["/** Capture fingerprint: everything that changes pixels without a code change (ADR 0005 phase 4). */\nimport type { CaptureEnvironment } from \"../adapters/capture-runner.ts\";\nimport { sha256 } from \"../utils/hash.ts\";\nimport type { Viewport } from \"./adapter.ts\";\nimport { SIZING_DEFAULTS } from \"./sizing.ts\";\n\n/**\n * What this build of StoryShelf measures the environment with (the browser probe). Bump it only\n * together with a probe change, and list in {@link REMEASURED_IN} the components whose measurement\n * changed. A fingerprint stored without it is version 1.\n */\nexport const MEASUREMENT_VERSION = 1;\n\n/** The components a comparison reads: the environment fields and the screenshot sizing. */\nexport type MeasuredField =\n | \"engine\"\n | \"browserVersion\"\n | \"platform\"\n | \"deviceScaleFactor\"\n | \"fontsHash\"\n | \"sizing\";\n\n/**\n * Components whose measurement changed when a version was introduced, by that version. Version 1\n * is the baseline of the scheme, so nothing is listed yet. Between two versions, a listed\n * component is compared as unknown, never as a mismatch (it must not cause an environment migration).\n */\n// A test that needs a re-measured component adds a version here and deletes it again.\nexport const REMEASURED_IN: Record<number, readonly MeasuredField[]> = {};\n\n/** Components whose measurement changed, by the version that changed it. Tests pass their own. */\nexport type MeasurementHistory = Readonly<Record<number, readonly MeasuredField[]>>;\n\n/** The components behind a snapshot's `infraHash`, kept so a mismatch can say what changed. */\nexport interface Fingerprint {\n /**\n * How the environment components were measured (see {@link MEASUREMENT_VERSION}). Absent means\n * version 1. It is not part of `infraHash`: the baseline's identity must not change when only\n * the measurement does.\n */\n measurementVersion?: number | undefined;\n engine: string;\n browserVersion: string | null;\n platform: string | null;\n deviceScaleFactor: number | null;\n fontsHash: string | null;\n viewport: { width: number; height: number };\n sizing: typeof SIZING_DEFAULTS;\n}\n\n/** Build the fingerprint of one capture. Missing environment fields stay `null` (runner did not report them). */\nexport function buildFingerprint(\n engine: string,\n viewport: Viewport,\n environment: CaptureEnvironment | undefined,\n): Fingerprint {\n return {\n measurementVersion: MEASUREMENT_VERSION,\n engine,\n browserVersion: environment?.browserVersion ?? null,\n platform: environment?.platform ?? null,\n deviceScaleFactor: environment?.deviceScaleFactor ?? null,\n fontsHash: environment?.fontsHash ?? null,\n viewport: { width: viewport.width, height: viewport.height },\n sizing: SIZING_DEFAULTS,\n };\n}\n\n/** Stable hash of a fingerprint: the value stored as `infraHash`. `measurementVersion` is left out on purpose. */\nexport function fingerprintHash(fingerprint: Fingerprint): string {\n return sha256(\n JSON.stringify([\n fingerprint.engine,\n fingerprint.browserVersion,\n fingerprint.platform,\n fingerprint.deviceScaleFactor,\n fingerprint.fontsHash,\n fingerprint.viewport.width,\n fingerprint.viewport.height,\n fingerprint.sizing,\n ]),\n );\n}\n\n/** The components whose change is an environment migration. Viewport is not one (see below). */\nconst ENVIRONMENT_FIELDS = [\n \"engine\",\n \"browserVersion\",\n \"platform\",\n \"deviceScaleFactor\",\n \"fontsHash\",\n] as const;\n\n/** The version a fingerprint was measured with; one stored before versions existed is version 1. */\nexport function measurementVersionOf(fingerprint: Fingerprint): number {\n return fingerprint.measurementVersion ?? 1;\n}\n\n/**\n * Components measured differently in the two fingerprints: those listed for any version between\n * theirs. Empty when the versions match (nothing about a same-version comparison changes).\n */\nexport function remeasuredFields(\n from: Fingerprint,\n to: Fingerprint,\n history: MeasurementHistory = REMEASURED_IN,\n): MeasuredField[] {\n const [low, high] = [measurementVersionOf(from), measurementVersionOf(to)].toSorted(\n (left, right) => left - right,\n );\n const fields = Object.entries(history)\n .filter(([version]) => Number(version) > (low ?? 1) && Number(version) <= (high ?? 1))\n .flatMap(([, listed]) => listed);\n return [...new Set(fields)];\n}\n\n/**\n * Names of the environment components that differ; `[\"unknown\"]` when the earlier fingerprint was\n * not recorded. A component one side did not report (`null`: a runner that does not report it, a\n * capture spooled before a browser crash, a failed `fc-list`) is unknown, not changed, and so is a\n * component measured differently in the two measurement versions. Viewport is never a change\n * here: its size comes from story parameters (the PR's own code), so a new size must be reviewed,\n * and it diffs on its own as a dimension change.\n */\nexport function changedFields(\n from: Fingerprint | null,\n to: Fingerprint,\n history: MeasurementHistory = REMEASURED_IN,\n): string[] {\n if (!from) {\n return [\"unknown\"];\n }\n const remeasured = new Set<string>(remeasuredFields(from, to, history));\n return [\n ...ENVIRONMENT_FIELDS.filter(\n (key) => !remeasured.has(key) && known(from[key]) && known(to[key]) && from[key] !== to[key],\n ),\n ...(remeasured.has(\"sizing\") || JSON.stringify(from.sizing) === JSON.stringify(to.sizing)\n ? []\n : [\"sizing\"]),\n ];\n}\n\n/**\n * Components that could not be compared because their measurement changed between the two\n * fingerprints' versions. The review says the environment is partly unknown; nothing migrates.\n */\nexport function unknownFields(\n from: Fingerprint | null,\n to: Fingerprint,\n history: MeasurementHistory = REMEASURED_IN,\n): string[] {\n return from ? remeasuredFields(from, to, history) : [];\n}\n\nfunction known(value: unknown): boolean {\n return value !== null && value !== undefined;\n}\n\n/**\n * The fingerprint to store on a baseline: the capture's, with any component it did not report kept\n * from the baseline's earlier fingerprint, so an unknown component never overwrites a known one.\n */\nexport function mergeFingerprint(\n earlier: Fingerprint | null,\n now: Fingerprint,\n history: MeasurementHistory = REMEASURED_IN,\n): Fingerprint {\n // A value measured under another version is not carried over a changed measurement.\n const stale = new Set<string>(earlier ? remeasuredFields(earlier, now, history) : []);\n const kept = <Key extends \"browserVersion\" | \"platform\" | \"deviceScaleFactor\" | \"fontsHash\">(\n key: Key,\n ): Fingerprint[Key] => now[key] ?? (stale.has(key) ? null : (earlier?.[key] ?? null));\n return {\n ...now,\n browserVersion: kept(\"browserVersion\"),\n platform: kept(\"platform\"),\n deviceScaleFactor: kept(\"deviceScaleFactor\"),\n fontsHash: kept(\"fontsHash\"),\n };\n}\n\n/** Parse a stored fingerprint; null for a missing or malformed value. */\nexport function parseFingerprint(raw: string | null | undefined): Fingerprint | null {\n if (!raw) {\n return null;\n }\n try {\n const value = JSON.parse(raw) as Partial<Fingerprint>;\n return typeof value.engine === \"string\" && value.viewport ? (value as Fingerprint) : null;\n } catch {\n return null;\n }\n}\n\n/** What a migrated snapshot records: which components changed, and the before and after. */\nexport interface EnvironmentChange {\n changed: string[];\n /** Components not compared because their measurement changed between versions (absent: none). */\n unknown?: string[] | undefined;\n from: Fingerprint | null;\n to: Fingerprint;\n}\n"],"mappings":";;;;;;;;;;AA4BA,MAAa,gBAA0D,CAAC;;AAuBxE,SAAgB,iBACd,QACA,UACA,aACa;CACb,OAAO;EACL,oBAAA;EACA;EACA,gBAAgB,aAAa,kBAAkB;EAC/C,UAAU,aAAa,YAAY;EACnC,mBAAmB,aAAa,qBAAqB;EACrD,WAAW,aAAa,aAAa;EACrC,UAAU;GAAE,OAAO,SAAS;GAAO,QAAQ,SAAS;EAAO;EAC3D,QAAQ;CACV;AACF;;AAGA,SAAgB,gBAAgB,aAAkC;CAChE,OAAO,OACL,KAAK,UAAU;EACb,YAAY;EACZ,YAAY;EACZ,YAAY;EACZ,YAAY;EACZ,YAAY;EACZ,YAAY,SAAS;EACrB,YAAY,SAAS;EACrB,YAAY;CACd,CAAC,CACH;AACF;;AAGA,MAAM,qBAAqB;CACzB;CACA;CACA;CACA;CACA;AACF;;AAGA,SAAgB,qBAAqB,aAAkC;CACrE,OAAO,YAAY,sBAAsB;AAC3C;;;;;AAMA,SAAgB,iBACd,MACA,IACA,UAA8B,eACb;CACjB,MAAM,CAAC,KAAK,QAAQ,CAAC,qBAAqB,IAAI,GAAG,qBAAqB,EAAE,CAAC,CAAC,CAAC,UACxE,MAAM,UAAU,OAAO,KAC1B;CACA,MAAM,SAAS,OAAO,QAAQ,OAAO,CAAC,CACnC,QAAQ,CAAC,aAAa,OAAO,OAAO,KAAK,OAAO,MAAM,OAAO,OAAO,MAAM,QAAQ,EAAE,CAAC,CACrF,SAAS,GAAG,YAAY,MAAM;CACjC,OAAO,CAAC,GAAG,IAAI,IAAI,MAAM,CAAC;AAC5B;;;;;;;;;AAUA,SAAgB,cACd,MACA,IACA,UAA8B,eACpB;CACV,IAAI,CAAC,MACH,OAAO,CAAC,SAAS;CAEnB,MAAM,aAAa,IAAI,IAAY,iBAAiB,MAAM,IAAI,OAAO,CAAC;CACtE,OAAO,CACL,GAAG,mBAAmB,QACnB,QAAQ,CAAC,WAAW,IAAI,GAAG,KAAK,MAAM,KAAK,IAAI,KAAK,MAAM,GAAG,IAAI,KAAK,KAAK,SAAS,GAAG,IAC1F,GACA,GAAI,WAAW,IAAI,QAAQ,KAAK,KAAK,UAAU,KAAK,MAAM,MAAM,KAAK,UAAU,GAAG,MAAM,IACpF,CAAC,IACD,CAAC,QAAQ,CACf;AACF;;;;;AAMA,SAAgB,cACd,MACA,IACA,UAA8B,eACpB;CACV,OAAO,OAAO,iBAAiB,MAAM,IAAI,OAAO,IAAI,CAAC;AACvD;AAEA,SAAS,MAAM,OAAyB;CACtC,OAAO,UAAU,QAAQ,UAAU,KAAA;AACrC;;;;;AAMA,SAAgB,iBACd,SACA,KACA,UAA8B,eACjB;CAEb,MAAM,QAAQ,IAAI,IAAY,UAAU,iBAAiB,SAAS,KAAK,OAAO,IAAI,CAAC,CAAC;CACpF,MAAM,QACJ,QACqB,IAAI,SAAS,MAAM,IAAI,GAAG,IAAI,OAAQ,UAAU,QAAQ;CAC/E,OAAO;EACL,GAAG;EACH,gBAAgB,KAAK,gBAAgB;EACrC,UAAU,KAAK,UAAU;EACzB,mBAAmB,KAAK,mBAAmB;EAC3C,WAAW,KAAK,WAAW;CAC7B;AACF;;AAGA,SAAgB,iBAAiB,KAAoD;CACnF,IAAI,CAAC,KACH,OAAO;CAET,IAAI;EACF,MAAM,QAAQ,KAAK,MAAM,GAAG;EAC5B,OAAO,OAAO,MAAM,WAAW,YAAY,MAAM,WAAY,QAAwB;CACvF,QAAQ;EACN,OAAO;CACT;AACF"}
|
|
@@ -15,7 +15,7 @@ import { SnapshotModel } from "../models/snapshot.mjs";
|
|
|
15
15
|
import { mapLimit } from "../utils/map-limit.mjs";
|
|
16
16
|
import { freezeComparison, sourceSnapshotId } from "./comparison-reference.mjs";
|
|
17
17
|
import { diffStoredScreenshots, writeDiffOverlay } from "./diff-record.mjs";
|
|
18
|
-
import { buildFingerprint, changedFields, fingerprintHash, mergeFingerprint, parseFingerprint } from "./fingerprint.mjs";
|
|
18
|
+
import { buildFingerprint, changedFields, fingerprintHash, mergeFingerprint, parseFingerprint, unknownFields } from "./fingerprint.mjs";
|
|
19
19
|
/**
|
|
20
20
|
* Persist a completed capture run: write screenshots, diff against the branch
|
|
21
21
|
* baseline, create snapshots, and finalize the build.
|
|
@@ -295,11 +295,14 @@ function environmentChange(baseline, now) {
|
|
|
295
295
|
const from = parseFingerprint(baseline.fingerprint);
|
|
296
296
|
if (!from) return null;
|
|
297
297
|
const changed = changedFields(from, now.fingerprint);
|
|
298
|
-
|
|
298
|
+
const unknown = unknownFields(from, now.fingerprint);
|
|
299
|
+
if (changed.length === 0 && unknown.length === 0) return null;
|
|
300
|
+
return {
|
|
299
301
|
changed,
|
|
302
|
+
...unknown.length > 0 ? { unknown } : {},
|
|
300
303
|
from,
|
|
301
304
|
to: now.fingerprint
|
|
302
|
-
}
|
|
305
|
+
};
|
|
303
306
|
}
|
|
304
307
|
/**
|
|
305
308
|
* A changed environment migrates the baseline without a diff only on the default branch, which is
|
|
@@ -327,7 +330,7 @@ async function createWithBaseline(ctx, capture, viewport, screenshot, baseline)
|
|
|
327
330
|
sourceSnapshotId: await sourceSnapshotId(ctx.db, baseline)
|
|
328
331
|
})
|
|
329
332
|
};
|
|
330
|
-
if (change && ctx.build.isDefault) await persistMigration(input, change);
|
|
333
|
+
if (change && change.changed.length > 0 && ctx.build.isDefault) await persistMigration(input, change);
|
|
331
334
|
else await persistDiff(input);
|
|
332
335
|
}
|
|
333
336
|
/** The fingerprint to write on the baseline: the capture's, keeping components it did not report. */
|