autokap 1.9.1 → 1.9.3
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/cli-runner.js +90 -3
- package/dist/execution-types.d.ts +3 -2
- package/dist/mockup.d.ts +10 -1
- package/dist/mockup.js +16 -1
- package/dist/opcode-runner.js +84 -9
- package/dist/postcondition.js +10 -3
- package/dist/safari-browser-bar.d.ts +13 -5
- package/dist/safari-browser-bar.js +134 -53
- package/dist/web-playwright-local.js +0 -0
- package/package.json +1 -1
package/dist/cli-runner.js
CHANGED
|
@@ -217,12 +217,51 @@ export async function runCapture(options) {
|
|
|
217
217
|
// badge on the preset while it captures. Best-effort + local-only: skip dry
|
|
218
218
|
// runs and cloud runs (AUTOKAP_RUN_ID is set on cloud runners, which own
|
|
219
219
|
// their own capture_runs row). A failure here must never block the capture.
|
|
220
|
-
|
|
220
|
+
const isLocalRegisteredRun = !options.dryRun && !process.env.AUTOKAP_RUN_ID;
|
|
221
|
+
if (isLocalRegisteredRun) {
|
|
221
222
|
await postRunStart(config, runId, program.presetId, program.variants.length, options.env);
|
|
222
223
|
}
|
|
224
|
+
// Ctrl-C handling: a local run that registered itself server-side must never
|
|
225
|
+
// leave the preset stuck on the "En cours" badge. On SIGINT/SIGTERM we abort
|
|
226
|
+
// the in-flight capture (so the finally-block cleanup runs) and best-effort
|
|
227
|
+
// tell the server the run was interrupted — otherwise the capture_runs row
|
|
228
|
+
// sits in `running` and the preset stays "capturing" until the 15-min stale
|
|
229
|
+
// cutoff. A caller-supplied abortSignal is forwarded into the same controller
|
|
230
|
+
// so both paths converge on one cancellation.
|
|
231
|
+
const abortController = new AbortController();
|
|
232
|
+
if (options.abortSignal) {
|
|
233
|
+
if (options.abortSignal.aborted) {
|
|
234
|
+
abortController.abort(options.abortSignal.reason);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
options.abortSignal.addEventListener('abort', () => abortController.abort(options.abortSignal.reason), { once: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let interrupted = false;
|
|
241
|
+
let abortNotified = false;
|
|
242
|
+
const notifyAborted = async () => {
|
|
243
|
+
if (abortNotified || !isLocalRegisteredRun)
|
|
244
|
+
return;
|
|
245
|
+
abortNotified = true;
|
|
246
|
+
await postRunAborted(config, runId, program.presetId, options.env);
|
|
247
|
+
};
|
|
248
|
+
const onInterrupt = (signal) => {
|
|
249
|
+
if (interrupted) {
|
|
250
|
+
// Second interrupt: the user wants out now. Hard-exit with the
|
|
251
|
+
// conventional 128+SIGINT(2) code; the best-effort notify already fired.
|
|
252
|
+
process.exit(130);
|
|
253
|
+
}
|
|
254
|
+
interrupted = true;
|
|
255
|
+
logger.warn(`[capture] ${signal} received — stopping the run and notifying AutoKap…`);
|
|
256
|
+
abortController.abort(new Error('User interrupted (Ctrl-C)'));
|
|
257
|
+
};
|
|
258
|
+
if (isLocalRegisteredRun) {
|
|
259
|
+
process.on('SIGINT', onInterrupt);
|
|
260
|
+
process.on('SIGTERM', onInterrupt);
|
|
261
|
+
}
|
|
223
262
|
const runOptions = {
|
|
224
263
|
recoveryChain,
|
|
225
|
-
abortSignal:
|
|
264
|
+
abortSignal: abortController.signal,
|
|
226
265
|
maxParallelVariants,
|
|
227
266
|
llmConfig,
|
|
228
267
|
presetName: program.presetId,
|
|
@@ -292,15 +331,30 @@ export async function runCapture(options) {
|
|
|
292
331
|
};
|
|
293
332
|
let runResult;
|
|
294
333
|
let cliResult;
|
|
334
|
+
let runAborted = false;
|
|
295
335
|
try {
|
|
296
336
|
runResult = await executeProgram(program, createAdapter, runOptions);
|
|
337
|
+
runAborted = interrupted || runResult.error === 'aborted';
|
|
297
338
|
if (runResult.success) {
|
|
298
339
|
logger.info(`[capture] Run completed successfully — ${runResult.telemetry.totalOpcodes} opcodes, ${runResult.telemetry.recoveredOpcodes} recovered, ${runResult.totalDurationMs}ms`);
|
|
299
340
|
}
|
|
341
|
+
else if (runAborted) {
|
|
342
|
+
logger.warn('[capture] Run interrupted by the user — skipping artifact upload.');
|
|
343
|
+
}
|
|
300
344
|
else {
|
|
301
345
|
logger.error(`[capture] Run failed: ${runResult.error}`);
|
|
302
346
|
}
|
|
303
|
-
if (
|
|
347
|
+
if (runAborted) {
|
|
348
|
+
// Don't upload partial artifacts for a cancelled run; the server is told
|
|
349
|
+
// separately (notifyAborted) so the preset leaves the "capturing" state.
|
|
350
|
+
cliResult = {
|
|
351
|
+
success: false,
|
|
352
|
+
runId,
|
|
353
|
+
runResult,
|
|
354
|
+
error: 'Capture interrupted (Ctrl-C)',
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
else if (options.dryRun) {
|
|
304
358
|
logger.info(`[capture] DRY RUN complete — ${runResult.telemetry.totalOpcodes} opcodes executed, 0 captures, 0 credits charged`);
|
|
305
359
|
cliResult = { success: runResult.success, runId, runResult };
|
|
306
360
|
}
|
|
@@ -354,6 +408,16 @@ export async function runCapture(options) {
|
|
|
354
408
|
}
|
|
355
409
|
}
|
|
356
410
|
finally {
|
|
411
|
+
if (isLocalRegisteredRun) {
|
|
412
|
+
process.off('SIGINT', onInterrupt);
|
|
413
|
+
process.off('SIGTERM', onInterrupt);
|
|
414
|
+
}
|
|
415
|
+
// On interruption, mark the run terminal server-side regardless of the
|
|
416
|
+
// user's debug-log preference, so the preset never stays stuck "capturing".
|
|
417
|
+
// Idempotent with the error-log flush below (same capture_failed dedupeKey).
|
|
418
|
+
if (runAborted) {
|
|
419
|
+
await notifyAborted();
|
|
420
|
+
}
|
|
357
421
|
// AUT-149: export structured debug logs to AutoKap on capture failure.
|
|
358
422
|
// Best-effort — the LogCollector swallows network errors.
|
|
359
423
|
const shouldExport = options.exportDebugLogs !== false
|
|
@@ -693,6 +757,29 @@ async function postRunStart(config, runId, presetId, variantCount, env) {
|
|
|
693
757
|
logger.warn(`[capture] Run registration error: ${message}`);
|
|
694
758
|
}
|
|
695
759
|
}
|
|
760
|
+
// Best-effort terminal notification when a local run is interrupted (Ctrl-C).
|
|
761
|
+
// Marks the run failed server-side and clears the preset's "capturing" badge,
|
|
762
|
+
// so an aborted capture can't leave the preset stuck "En cours". Never throws.
|
|
763
|
+
async function postRunAborted(config, runId, presetId, env) {
|
|
764
|
+
try {
|
|
765
|
+
const response = await fetch(`${config.apiBaseUrl}/api/cli/runs`, {
|
|
766
|
+
method: 'PATCH',
|
|
767
|
+
headers: {
|
|
768
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
769
|
+
'Content-Type': 'application/json',
|
|
770
|
+
},
|
|
771
|
+
body: JSON.stringify({ runId, presetId, status: 'aborted', env }),
|
|
772
|
+
signal: AbortSignal.timeout(10_000),
|
|
773
|
+
});
|
|
774
|
+
if (!response.ok) {
|
|
775
|
+
logger.warn(`[capture] Failed to report interruption (HTTP ${response.status}); the preset may show "in progress" until the stale cutoff`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
780
|
+
logger.warn(`[capture] Interruption report error: ${message}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
696
783
|
async function uploadResults(config, program, result, runId, sessionId, provenance) {
|
|
697
784
|
const artifactJobs = result.variantResults.flatMap((variant) => {
|
|
698
785
|
const variantSpec = program.variants.find((entry) => entry.id === variant.variantId);
|
|
@@ -600,10 +600,11 @@ export interface ExecutionProgram {
|
|
|
600
600
|
*/
|
|
601
601
|
deviceConfigs?: Record<string, DeviceConfig>;
|
|
602
602
|
/**
|
|
603
|
-
* Project-level
|
|
603
|
+
* Project-level decorative URL used to decorate browser mockups. The CLI
|
|
604
604
|
* substitutes the captured origin (typically a local dev server) with this
|
|
605
605
|
* value via `transformBrowserUrl` before baking it into the browser bar.
|
|
606
|
-
*
|
|
606
|
+
* AUT-269: derived automatically from the project's prod environment base
|
|
607
|
+
* URL (absent when no prod environment is configured), not a separate field.
|
|
607
608
|
*/
|
|
608
609
|
publicUrl?: string;
|
|
609
610
|
/**
|
package/dist/mockup.d.ts
CHANGED
|
@@ -204,18 +204,27 @@ export interface MockupOptions {
|
|
|
204
204
|
height: number;
|
|
205
205
|
};
|
|
206
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Status bar default: shown only on phone mockups. Tablet, laptop, and browser
|
|
209
|
+
* categories default to off (browser is additionally excluded at render time).
|
|
210
|
+
* An explicit user choice (`mockupOptions.showStatusBar`) always overrides this.
|
|
211
|
+
*/
|
|
212
|
+
export declare function defaultShowStatusBar(category: DeviceCategory): boolean;
|
|
207
213
|
/**
|
|
208
214
|
* Resolve the two per-variant frame decisions shared by both render paths (CLI direct-upload
|
|
209
215
|
* framing and the cloud legacy-multipart route): a variant's own `mockupOptions` wins, falling
|
|
210
216
|
* back to the viewport-inferred orientation and the deprecated program-level `applyStatusBar`.
|
|
211
217
|
* Pure + exported so the precedence is tested once instead of in two duplicated call sites.
|
|
218
|
+
*
|
|
219
|
+
* `showStatusBar` is left `undefined` when neither the variant nor the legacy fallback set it,
|
|
220
|
+
* so `applyDeviceFrame` can apply the category-aware default (phones on, everything else off).
|
|
212
221
|
*/
|
|
213
222
|
export declare function resolveVariantFrameOptions(mockupOptions: MockupOptions | undefined, fallback: {
|
|
214
223
|
orientation?: MockupOrientation;
|
|
215
224
|
showStatusBar?: boolean;
|
|
216
225
|
}): {
|
|
217
226
|
orientation?: MockupOrientation;
|
|
218
|
-
showStatusBar
|
|
227
|
+
showStatusBar?: boolean;
|
|
219
228
|
};
|
|
220
229
|
export interface ResolvedDeviceFrameDescriptor {
|
|
221
230
|
id: string;
|
package/dist/mockup.js
CHANGED
|
@@ -17,21 +17,33 @@ function getSupabaseMockupConfig() {
|
|
|
17
17
|
serviceKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Status bar default: shown only on phone mockups. Tablet, laptop, and browser
|
|
22
|
+
* categories default to off (browser is additionally excluded at render time).
|
|
23
|
+
* An explicit user choice (`mockupOptions.showStatusBar`) always overrides this.
|
|
24
|
+
*/
|
|
25
|
+
export function defaultShowStatusBar(category) {
|
|
26
|
+
return category === 'phone';
|
|
27
|
+
}
|
|
20
28
|
/**
|
|
21
29
|
* Resolve the two per-variant frame decisions shared by both render paths (CLI direct-upload
|
|
22
30
|
* framing and the cloud legacy-multipart route): a variant's own `mockupOptions` wins, falling
|
|
23
31
|
* back to the viewport-inferred orientation and the deprecated program-level `applyStatusBar`.
|
|
24
32
|
* Pure + exported so the precedence is tested once instead of in two duplicated call sites.
|
|
33
|
+
*
|
|
34
|
+
* `showStatusBar` is left `undefined` when neither the variant nor the legacy fallback set it,
|
|
35
|
+
* so `applyDeviceFrame` can apply the category-aware default (phones on, everything else off).
|
|
25
36
|
*/
|
|
26
37
|
export function resolveVariantFrameOptions(mockupOptions, fallback) {
|
|
27
38
|
return {
|
|
28
39
|
orientation: mockupOptions?.orientation ?? fallback.orientation,
|
|
29
|
-
showStatusBar: mockupOptions?.showStatusBar ?? fallback.showStatusBar
|
|
40
|
+
showStatusBar: mockupOptions?.showStatusBar ?? fallback.showStatusBar,
|
|
30
41
|
};
|
|
31
42
|
}
|
|
32
43
|
const DEFAULT_MOCKUP_OPTIONS = {
|
|
33
44
|
orientation: 'portrait',
|
|
34
45
|
outputScale: 2,
|
|
46
|
+
// Placeholder only — applyDeviceFrame re-resolves this per device category (see defaultShowStatusBar).
|
|
35
47
|
showStatusBar: true,
|
|
36
48
|
showSafeAreaTop: true,
|
|
37
49
|
showSafeAreaBottom: true,
|
|
@@ -523,6 +535,9 @@ export async function applyDeviceFrame(screenshot, deviceId, options) {
|
|
|
523
535
|
if (!config)
|
|
524
536
|
throw new Error(`Unknown device frame: ${deviceId}`);
|
|
525
537
|
const opts = { ...DEFAULT_MOCKUP_OPTIONS, ...options };
|
|
538
|
+
// Status bar defaults to on for phones only; tablet/laptop/browser default to off.
|
|
539
|
+
// An explicit user choice wins; browser is additionally excluded at render time below.
|
|
540
|
+
opts.showStatusBar = options?.showStatusBar ?? defaultShowStatusBar(config.category);
|
|
526
541
|
const requestedOrientation = opts.orientation ?? 'portrait';
|
|
527
542
|
// Normalize against supported orientations so stale extra configs in Supabase
|
|
528
543
|
// do not override landscape-only desktop/tablet/browser frames.
|
package/dist/opcode-runner.js
CHANGED
|
@@ -156,8 +156,31 @@ export async function executeProgram(program, createAdapter, options = {}) {
|
|
|
156
156
|
});
|
|
157
157
|
await Promise.all(workers);
|
|
158
158
|
const completedVariantResults = variantResults.filter((result) => Boolean(result));
|
|
159
|
-
const aborted = options.abortSignal?.aborted && completedVariantResults.length < program.variants.length;
|
|
160
|
-
|
|
159
|
+
const aborted = Boolean(options.abortSignal?.aborted) && completedVariantResults.length < program.variants.length;
|
|
160
|
+
// Fail-closed on incomplete delivery. Previously `success` was computed purely
|
|
161
|
+
// from the SURVIVING variant results, so a run that silently dropped variants
|
|
162
|
+
// (a variant finishing "ok" while persisting no artifact) was recorded as a
|
|
163
|
+
// clean success — a 4-of-24 capture looked identical to a full run, got billed,
|
|
164
|
+
// was never retried, and surfaced nothing. Require that every variant ran,
|
|
165
|
+
// succeeded, AND produced its expected number of artifacts; name the deficient
|
|
166
|
+
// variants so the failure is actionable and the runtime cause is diagnosable
|
|
167
|
+
// straight from the run log. Dry runs intentionally skip capture opcodes (and
|
|
168
|
+
// programs with no capture points have nothing to enforce), so they are exempt
|
|
169
|
+
// from the artifact check but still require every variant to succeed.
|
|
170
|
+
const expectedArtifactsPerVariant = program.steps.filter((step) => isArtifactProducingOpcode(step.kind)).length;
|
|
171
|
+
const enforceArtifactCompleteness = !options.dryRun && expectedArtifactsPerVariant > 0;
|
|
172
|
+
const deficientVariants = program.variants
|
|
173
|
+
.map((variant, index) => ({ variant, result: variantResults[index] }))
|
|
174
|
+
.filter(({ result }) => !result
|
|
175
|
+
|| !result.success
|
|
176
|
+
|| (enforceArtifactCompleteness && result.artifacts.length < expectedArtifactsPerVariant))
|
|
177
|
+
.map(({ variant, result }) => !result
|
|
178
|
+
? `${variant.id} (not executed)`
|
|
179
|
+
: !result.success
|
|
180
|
+
? `${variant.id} (failed: ${result.error ?? 'unknown error'})`
|
|
181
|
+
: `${variant.id} (${result.artifacts.length}/${expectedArtifactsPerVariant} artifacts)`);
|
|
182
|
+
const incompleteDelivery = deficientVariants.length > 0;
|
|
183
|
+
const success = !aborted && completedVariantResults.length > 0 && !incompleteDelivery;
|
|
161
184
|
const detectedAppVersion = completedVariantResults.reduce((acc, variantResult) => acc ?? (variantResult.detectedAppVersion ?? null), null);
|
|
162
185
|
// AUT-241 — surface (don't mask) cuts: aggregate every recording warning from
|
|
163
186
|
// each variant's clip/video artifacts. Diagnostic only; never affects success.
|
|
@@ -172,7 +195,11 @@ export async function executeProgram(program, createAdapter, options = {}) {
|
|
|
172
195
|
totalDurationMs: Date.now() - startTime,
|
|
173
196
|
detectedAppVersion,
|
|
174
197
|
warnings: aggregatedWarnings.length ? aggregatedWarnings : undefined,
|
|
175
|
-
error: aborted
|
|
198
|
+
error: aborted
|
|
199
|
+
? 'aborted'
|
|
200
|
+
: success
|
|
201
|
+
? undefined
|
|
202
|
+
: `incomplete run: ${deficientVariants.length}/${program.variants.length} variant(s) did not deliver expected artifacts — ${deficientVariants.join('; ')}`,
|
|
176
203
|
failureKind: success ? undefined : completedVariantResults.find(v => v.failureKind)?.failureKind,
|
|
177
204
|
};
|
|
178
205
|
}
|
|
@@ -320,6 +347,13 @@ function softSkipResult(opcode, index, startTime, reason, telemetry) {
|
|
|
320
347
|
error: reason,
|
|
321
348
|
};
|
|
322
349
|
}
|
|
350
|
+
/** Opcodes whose ACTION produces a persisted artifact (a screenshot or a finalized
|
|
351
|
+
* video clip). A passing postcondition does NOT imply the artifact exists, so these
|
|
352
|
+
* get special handling on the recovery path (executeOpcode → failWithRecovery) and
|
|
353
|
+
* in the run-level completeness gate (executeProgram). */
|
|
354
|
+
function isArtifactProducingOpcode(kind) {
|
|
355
|
+
return kind === 'CAPTURE_SCREENSHOT' || kind === 'END_CLIP';
|
|
356
|
+
}
|
|
323
357
|
async function executeOpcode(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, opcodeTimings, artifacts, options, variantId, executionState, artifactPlan, mockDataGroups, currentVariant, credentials) {
|
|
324
358
|
const startTime = Date.now();
|
|
325
359
|
const effectiveTimeoutMs = resolveOpcodeTimeoutMs(opcode);
|
|
@@ -333,6 +367,47 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
333
367
|
const getProgress = makeProgressGetter(adapter);
|
|
334
368
|
const actionEffectPolicy = getOpcodeActionEffectPolicy(opcode);
|
|
335
369
|
const isSoft = isSoftOpcodeKind(opcode.kind);
|
|
370
|
+
// Snapshot so we can tell whether THIS opcode produced its artifact. A recovered
|
|
371
|
+
// CAPTURE_SCREENSHOT must not pass as a phantom success when no screenshot was
|
|
372
|
+
// taken — the postcondition (e.g. element_visible) can pass without a capture.
|
|
373
|
+
const artifactCountAtStart = artifacts.length;
|
|
374
|
+
// Wraps handleFailure: when recovery succeeds for an artifact-producing opcode
|
|
375
|
+
// that pushed NO artifact for itself, re-run the capture once so the artifact
|
|
376
|
+
// truly exists; hard-fail if it still can't. END_CLIP finalization is stateful,
|
|
377
|
+
// so it hard-fails without re-running. Without this a "recovered"
|
|
378
|
+
// CAPTURE_SCREENSHOT finishes ok with no screenshot — the silent partial loss.
|
|
379
|
+
const failWithRecovery = async (reason) => {
|
|
380
|
+
const failureResult = await handleFailure(opcode, index, adapter, verifier, breaker, recoveryChain, telemetry, healerPatches, options, executionState, variantId, currentVariant, startTime, deadlineMs, globalDeadlineMs, effectiveTimeoutMs, reason);
|
|
381
|
+
if (failureResult.status !== 'recovered'
|
|
382
|
+
|| !isArtifactProducingOpcode(opcode.kind)
|
|
383
|
+
|| artifacts.length > artifactCountAtStart) {
|
|
384
|
+
return failureResult;
|
|
385
|
+
}
|
|
386
|
+
if (opcode.kind === 'CAPTURE_SCREENSHOT') {
|
|
387
|
+
const recaptureBudgetMs = getRemainingTimeMs(globalDeadlineMs);
|
|
388
|
+
if (recaptureBudgetMs > 0) {
|
|
389
|
+
let recapture;
|
|
390
|
+
try {
|
|
391
|
+
recapture = await withTimeout(() => executeOpcodeAction(opcode, index, adapter, artifacts, telemetry, currentVariant, executionState, artifactPlan, mockDataGroups, options, credentials), recaptureBudgetMs);
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
recapture = { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
395
|
+
}
|
|
396
|
+
if (recapture.success && artifacts.length > artifactCountAtStart) {
|
|
397
|
+
logger.debug(`[opcode ${index}] re-captured screenshot after recovery (${reason})`);
|
|
398
|
+
return failureResult;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
opcodeIndex: index,
|
|
404
|
+
kind: opcode.kind,
|
|
405
|
+
status: 'failed',
|
|
406
|
+
durationMs: Date.now() - startTime,
|
|
407
|
+
recoveryAttempts: failureResult.recoveryAttempts ?? 1,
|
|
408
|
+
error: `recovery succeeded but produced no ${opcode.kind === 'END_CLIP' ? 'clip' : 'screenshot'} artifact: ${reason}`,
|
|
409
|
+
};
|
|
410
|
+
};
|
|
336
411
|
// Track page context for circuit breaker
|
|
337
412
|
try {
|
|
338
413
|
const url = await adapter.getCurrentUrl();
|
|
@@ -358,7 +433,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
358
433
|
logger.debug(`[opcode ${index}] no budget left after captureBeforeState (deadline=${actionDeadlineMs}, now=${Date.now()})`);
|
|
359
434
|
if (isSoft)
|
|
360
435
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
361
|
-
return
|
|
436
|
+
return failWithRecovery(reason);
|
|
362
437
|
}
|
|
363
438
|
// For mediaMode='video', capture pre-action timing + bbox metadata inside
|
|
364
439
|
// the active clip window only. Opcodes outside a clip are not part of the
|
|
@@ -407,7 +482,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
407
482
|
const reason = result.error ?? 'action failed';
|
|
408
483
|
if (isSoft)
|
|
409
484
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
410
|
-
return
|
|
485
|
+
return failWithRecovery(reason);
|
|
411
486
|
}
|
|
412
487
|
// Verify postcondition — extend-on-progress up to the global deadline so a
|
|
413
488
|
// slow action no longer starves it (failure mode #3: clamped to ~1ms).
|
|
@@ -417,7 +492,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
417
492
|
logger.debug(`[opcode ${index}] no budget left for postcondition check`);
|
|
418
493
|
if (isSoft)
|
|
419
494
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
420
|
-
return
|
|
495
|
+
return failWithRecovery(reason);
|
|
421
496
|
}
|
|
422
497
|
const runtimePostcondition = resolveRuntimePostcondition(opcode);
|
|
423
498
|
const postStart = Date.now();
|
|
@@ -430,7 +505,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
430
505
|
const reason = `postcondition failed: ${postcondition.reason}`;
|
|
431
506
|
if (isSoft)
|
|
432
507
|
return softSkipResult(opcode, index, startTime, reason, telemetry);
|
|
433
|
-
return
|
|
508
|
+
return failWithRecovery(reason);
|
|
434
509
|
}
|
|
435
510
|
// Verify action effects through the shared policy. Weak `any_change`
|
|
436
511
|
// postconditions are only meaningful if this verifier observes a real
|
|
@@ -446,7 +521,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
446
521
|
`postcondition passed, treating as redundant-but-successful`);
|
|
447
522
|
}
|
|
448
523
|
else {
|
|
449
|
-
return
|
|
524
|
+
return failWithRecovery(`action had no effect: ${verification.summary}`);
|
|
450
525
|
}
|
|
451
526
|
}
|
|
452
527
|
}
|
|
@@ -480,7 +555,7 @@ async function executeOpcode(opcode, index, adapter, verifier, breaker, recovery
|
|
|
480
555
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
481
556
|
if (isSoft)
|
|
482
557
|
return softSkipResult(opcode, index, startTime, errorMsg, telemetry);
|
|
483
|
-
return
|
|
558
|
+
return failWithRecovery(errorMsg);
|
|
484
559
|
}
|
|
485
560
|
}
|
|
486
561
|
/** Post-action breathing room (ms) injected between visible interactions
|
package/dist/postcondition.js
CHANGED
|
@@ -100,8 +100,8 @@ function compileRoutePattern(pattern) {
|
|
|
100
100
|
// Support glob-like patterns: ** matches anything (incl. slashes / empty),
|
|
101
101
|
// * matches a single path segment, ? matches one non-slash char.
|
|
102
102
|
// Tokenize in one pass so the `*` rewrite doesn't clobber the `*` produced
|
|
103
|
-
// by the `**` rewrite (e.g. `/home**` must compile to
|
|
104
|
-
//
|
|
103
|
+
// by the `**` rewrite (e.g. `/home**` must compile to `/home.*`, not
|
|
104
|
+
// `/home.[^/]*` which would reject `/home` itself).
|
|
105
105
|
let regexStr = '';
|
|
106
106
|
for (let i = 0; i < pattern.length; i++) {
|
|
107
107
|
const ch = pattern[i];
|
|
@@ -122,7 +122,14 @@ function compileRoutePattern(pattern) {
|
|
|
122
122
|
regexStr += ch;
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
|
|
125
|
+
// Substring (contains) match — NOT anchored. Generated programs author bare
|
|
126
|
+
// patterns that are either a prefix of the real path (`/projects/` ⊂
|
|
127
|
+
// `/projects/<id>`) or a nested segment (`/tracking` ⊂
|
|
128
|
+
// `/projects/<id>/tracking`). An anchored `^…$` could match neither, which
|
|
129
|
+
// surfaced as a misleading "page stuck, no progress" failure after the
|
|
130
|
+
// navigation had actually succeeded. Callers needing strict matching pass an
|
|
131
|
+
// anchored regex (handled above).
|
|
132
|
+
return new RegExp(regexStr);
|
|
126
133
|
}
|
|
127
134
|
async function checkElementVisible(adapter, selector) {
|
|
128
135
|
// Primary check: use Playwright waitFor (fast, reliable)
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Safari macOS browser bar —
|
|
2
|
+
* Safari macOS browser bar — RECONSTRUCTED layout (not a stretched asset).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* The Figma export (1280×52 visible toolbar) used to be drawn with
|
|
5
|
+
* `preserveAspectRatio="none"` on a FIXED viewBox, so any target width whose
|
|
6
|
+
* aspect ratio differed from 1280:52 horizontally squished every icon — worse
|
|
7
|
+
* the wider the mockup got. This now mirrors the Chrome generator
|
|
8
|
+
* (`browser-bar.ts`): a height-driven UNIFORM scale + a DYNAMIC-width viewBox,
|
|
9
|
+
* with the toolbar's icon GLYPHS reused verbatim from the asset but re-anchored
|
|
10
|
+
* - left group (traffic lights, sidebar, back/forward) pinned left,
|
|
11
|
+
* - right group (share, open, new tab, tab overview) pinned right via `dx`,
|
|
12
|
+
* - center address pill: dynamic width, centered, inner icons follow its edges.
|
|
13
|
+
* Only the container and pill backgrounds are redrawn; the Figma blur/blend
|
|
14
|
+
* layers are dropped (resvg ignores them anyway and they are invisible on the
|
|
15
|
+
* white/dark toolbar). Colors and glyph shapes are preserved from the asset.
|
|
8
16
|
*
|
|
9
17
|
* Two outputs:
|
|
10
18
|
* - HTML (Playwright preview / React) — inline SVG inside a scaled wrapper
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Safari macOS browser bar —
|
|
2
|
+
* Safari macOS browser bar — RECONSTRUCTED layout (not a stretched asset).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* The Figma export (1280×52 visible toolbar) used to be drawn with
|
|
5
|
+
* `preserveAspectRatio="none"` on a FIXED viewBox, so any target width whose
|
|
6
|
+
* aspect ratio differed from 1280:52 horizontally squished every icon — worse
|
|
7
|
+
* the wider the mockup got. This now mirrors the Chrome generator
|
|
8
|
+
* (`browser-bar.ts`): a height-driven UNIFORM scale + a DYNAMIC-width viewBox,
|
|
9
|
+
* with the toolbar's icon GLYPHS reused verbatim from the asset but re-anchored
|
|
10
|
+
* - left group (traffic lights, sidebar, back/forward) pinned left,
|
|
11
|
+
* - right group (share, open, new tab, tab overview) pinned right via `dx`,
|
|
12
|
+
* - center address pill: dynamic width, centered, inner icons follow its edges.
|
|
13
|
+
* Only the container and pill backgrounds are redrawn; the Figma blur/blend
|
|
14
|
+
* layers are dropped (resvg ignores them anyway and they are invisible on the
|
|
15
|
+
* white/dark toolbar). Colors and glyph shapes are preserved from the asset.
|
|
8
16
|
*
|
|
9
17
|
* Two outputs:
|
|
10
18
|
* - HTML (Playwright preview / React) — inline SVG inside a scaled wrapper
|
|
11
19
|
* - SVG (sharp / resvg server compositing)
|
|
12
20
|
*/
|
|
13
21
|
import { SF_PRO_TEXT_REGULAR, SF_PRO_TEXT_SEMIBOLD, } from './sf-pro-fonts.js';
|
|
14
|
-
import { SAFARI_TOOLBAR_SVG, SAFARI_TOOLBAR_VIEWBOX, SAFARI_URL_PILL, } from './safari-toolbar-asset.js';
|
|
22
|
+
import { SAFARI_TOOLBAR_SVG, SAFARI_TOOLBAR_VIEWBOX, SAFARI_TOOLBAR_REF_W, SAFARI_TOOLBAR_REF_H, SAFARI_URL_PILL, } from './safari-toolbar-asset.js';
|
|
15
23
|
// ── Fonts ────────────────────────────────────────────────────────────────
|
|
16
24
|
const FONT_CSS_HTML = `<style>
|
|
17
25
|
@font-face{font-family:'SF Pro Text';src:local('SF Pro Text'),local('.SFNSText'),url('${SF_PRO_TEXT_REGULAR}') format('woff2');font-weight:400;font-style:normal}
|
|
@@ -19,12 +27,71 @@ const FONT_CSS_HTML = `<style>
|
|
|
19
27
|
</style>`;
|
|
20
28
|
// SVG output: fonts are NOT embedded inline. Resvg-js 2.6.2 does not honor
|
|
21
29
|
// `@font-face url(data:font/woff2;base64,…)` declarations inside SVG style
|
|
22
|
-
// blocks; fonts
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// the URL <text> element below remains for any consumer that DOES honor
|
|
26
|
-
// CSS @font-face (a future Resvg release, a different SVG renderer, etc.).
|
|
30
|
+
// blocks; fonts are supplied via `font.fontFiles` to the Resvg constructor by
|
|
31
|
+
// `mockup.ts::rasterizeSvg` (see `sf-pro-resvg-fonts.ts`). The `font-family`
|
|
32
|
+
// hint on the URL <text> element remains for renderers that DO honor it.
|
|
27
33
|
const FF = "'SF Pro Text',-apple-system,BlinkMacSystemFont,system-ui,sans-serif";
|
|
34
|
+
// ── Reference geometry (asset coordinate space, original 88-tall viewBox) ──
|
|
35
|
+
// The cropped viewBox is `0 18 1280 52`; pills/glyphs keep their native y so
|
|
36
|
+
// the vertical layout is identical to the asset — only X is reflowed.
|
|
37
|
+
const REF_W = SAFARI_TOOLBAR_REF_W; // 1280
|
|
38
|
+
const REF_H = SAFARI_TOOLBAR_REF_H; // 52
|
|
39
|
+
const VB_Y = SAFARI_TOOLBAR_VIEWBOX.y; // 18
|
|
40
|
+
const PILL_Y = SAFARI_URL_PILL.y; // 26
|
|
41
|
+
const PILL_H = SAFARI_URL_PILL.height; // 36
|
|
42
|
+
const PILL_RX = 18;
|
|
43
|
+
const SIDEBAR_PILL = { x: 95, w: 54 };
|
|
44
|
+
const NAV_PILL = { x: 163, w: 67 };
|
|
45
|
+
const RIGHT_PILL = { x: 1134, w: 139 };
|
|
46
|
+
const REF_URL_PILL = { x: SAFARI_URL_PILL.x, w: SAFARI_URL_PILL.width }; // 447 / 385
|
|
47
|
+
// macOS traffic lights (cx derived from x + r), y=37 d=14 → cy=44 r=7.
|
|
48
|
+
const TRAFFIC = [
|
|
49
|
+
{ x: 17, color: '#FF736A' },
|
|
50
|
+
{ x: 40, color: '#FEBC2E' },
|
|
51
|
+
{ x: 63, color: '#19C332' },
|
|
52
|
+
];
|
|
53
|
+
// End of the fixed left cluster (back/forward pill right edge).
|
|
54
|
+
const LEFT_BOUND = NAV_PILL.x + NAV_PILL.w; // 230
|
|
55
|
+
// Horizontal breathing room between the address pill and the side clusters.
|
|
56
|
+
const PILL_GAP = 28;
|
|
57
|
+
// ── Icon glyph extraction (reuse the asset's vector paths verbatim) ────────
|
|
58
|
+
// We pull only the icon glyphs (specific fills) out of the baked asset and
|
|
59
|
+
// drop the pill-background / blur / blend layers, which we redraw ourselves.
|
|
60
|
+
const ICON_FILLS = new Set(['#4C4C4C', '#808080', '#C6C6C6', '#E6E6E6']);
|
|
61
|
+
// Light → dark recolor for the reused glyphs (mirrors the asset's dark intent).
|
|
62
|
+
const DARK_ICON = {
|
|
63
|
+
'#4C4C4C': '#E4E4E4',
|
|
64
|
+
'#808080': '#8E8E93',
|
|
65
|
+
'#C6C6C6': '#6E6E73',
|
|
66
|
+
'#E6E6E6': '#4A4A4C',
|
|
67
|
+
};
|
|
68
|
+
let cachedGlyphs = null;
|
|
69
|
+
function assetGlyphs() {
|
|
70
|
+
if (cachedGlyphs)
|
|
71
|
+
return cachedGlyphs;
|
|
72
|
+
const glyphs = [];
|
|
73
|
+
const tagRe = /<path\b[^>]*?\/>/g;
|
|
74
|
+
let m;
|
|
75
|
+
while ((m = tagRe.exec(SAFARI_TOOLBAR_SVG)) !== null) {
|
|
76
|
+
const tag = m[0];
|
|
77
|
+
const dM = tag.match(/\sd="([^"]+)"/);
|
|
78
|
+
const fM = tag.match(/\sfill="([^"]+)"/);
|
|
79
|
+
if (!dM || !fM || !ICON_FILLS.has(fM[1]))
|
|
80
|
+
continue;
|
|
81
|
+
const xM = dM[1].match(/M\s*(-?[\d.]+)/);
|
|
82
|
+
glyphs.push({ d: dM[1], fill: fM[1], x0: xM ? parseFloat(xM[1]) : 0 });
|
|
83
|
+
}
|
|
84
|
+
cachedGlyphs = glyphs;
|
|
85
|
+
return glyphs;
|
|
86
|
+
}
|
|
87
|
+
const r = (n) => Math.round(n * 100) / 100;
|
|
88
|
+
function emitGlyph(g, isDark, tx = 0) {
|
|
89
|
+
if (!g)
|
|
90
|
+
return '';
|
|
91
|
+
const fill = isDark ? (DARK_ICON[g.fill] ?? g.fill) : g.fill;
|
|
92
|
+
const t = tx ? ` transform="translate(${r(tx)} 0)"` : '';
|
|
93
|
+
return `<path d="${g.d}" fill="${fill}"${t}/>`;
|
|
94
|
+
}
|
|
28
95
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
29
96
|
function escText(s) {
|
|
30
97
|
return s
|
|
@@ -36,62 +103,76 @@ function escText(s) {
|
|
|
36
103
|
function cleanUrl(raw) {
|
|
37
104
|
return (raw || 'apple.com').replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
38
105
|
}
|
|
39
|
-
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
.replace(/fill="white"/g, 'fill="#2C2C2E"')
|
|
60
|
-
.replace(/fill="#FFFFFF"/gi, 'fill="#2C2C2E"')
|
|
61
|
-
.replace(/fill="#F7F7F7"/gi, 'fill="#3A3A3C"')
|
|
62
|
-
.replace(/fill="#4C4C4C"/gi, 'fill="#E4E4E4"')
|
|
63
|
-
.replace(/fill="#808080"/gi, 'fill="#8E8E93"')
|
|
64
|
-
.replace(/fill="#C6C6C6"/gi, 'fill="#6E6E73"')
|
|
65
|
-
.replace(/fill="#E6E6E6"/gi, 'fill="#4A4A4C"');
|
|
66
|
-
}
|
|
106
|
+
// ── Core: reflowed toolbar SVG at the requested pixel dimensions ───────────
|
|
107
|
+
function buildSafariBarSvg(svgW, svgH, isDark, url) {
|
|
108
|
+
// Uniform scale driven by height; the viewBox width grows with the target so
|
|
109
|
+
// icons never stretch — only spacing and the address pill width adapt.
|
|
110
|
+
const s = svgH / REF_H;
|
|
111
|
+
const iw = Math.max(REF_W, Math.round(svgW / s));
|
|
112
|
+
const dx = iw - REF_W; // right-cluster shift
|
|
113
|
+
// Address pill: centered, dynamic width, clamped so it never collides with
|
|
114
|
+
// the fixed side clusters.
|
|
115
|
+
const rightBound = RIGHT_PILL.x + dx; // left edge of the right cluster
|
|
116
|
+
const maxPillW = Math.max(120, (rightBound - PILL_GAP) - (LEFT_BOUND + PILL_GAP));
|
|
117
|
+
let pillW = Math.min(720, Math.max(360, Math.round(iw * 0.36)));
|
|
118
|
+
pillW = Math.min(pillW, maxPillW);
|
|
119
|
+
let pillX = Math.round(iw / 2 - pillW / 2);
|
|
120
|
+
pillX = Math.max(LEFT_BOUND + PILL_GAP, Math.min(pillX, rightBound - PILL_GAP - pillW));
|
|
121
|
+
const pillRight = pillX + pillW;
|
|
122
|
+
// Tokens
|
|
123
|
+
const barBg = isDark ? '#2C2C2E' : '#FFFFFF';
|
|
124
|
+
const pillBg = isDark ? '#3A3A3C' : '#F0F0F0';
|
|
125
|
+
const border = isDark ? '#1F1F22' : '#E4E4E4';
|
|
67
126
|
const urlColor = isDark ? '#E4E4E4' : '#4C4C4C';
|
|
68
|
-
|
|
127
|
+
const gl = assetGlyphs();
|
|
128
|
+
const leftGlyphs = gl.filter(g => g.x0 < 240);
|
|
129
|
+
const readerGlyph = gl.find(g => g.fill === '#808080' && g.x0 < 640);
|
|
130
|
+
const reloadGlyph = gl.find(g => g.fill === '#808080' && g.x0 >= 640);
|
|
131
|
+
const rightGlyphs = gl.filter(g => g.x0 >= 1000);
|
|
132
|
+
const cy = PILL_Y + PILL_H / 2; // 44
|
|
133
|
+
const trafficLights = TRAFFIC.map(t => `<circle cx="${t.x + 7}" cy="${cy}" r="7" fill="${t.color}"/>`
|
|
134
|
+
+ `<circle cx="${t.x + 7}" cy="${cy}" r="6.75" fill="none" stroke="#000000" stroke-opacity="0.1" stroke-width="0.5"/>`).join('');
|
|
135
|
+
const pill = (x, w) => `<rect x="${r(x)}" y="${PILL_Y}" width="${r(w)}" height="${PILL_H}" rx="${PILL_RX}" fill="${pillBg}"/>`;
|
|
136
|
+
const leftLayer = leftGlyphs.map(g => emitGlyph(g, isDark)).join('');
|
|
137
|
+
const rightLayer = rightGlyphs.map(g => emitGlyph(g, isDark, dx)).join('');
|
|
138
|
+
const body = [
|
|
139
|
+
// Toolbar background (top corners are rounded by the wrapper's clip).
|
|
140
|
+
`<rect x="0" y="${VB_Y}" width="${iw}" height="${REF_H + 1}" fill="${barBg}"/>`,
|
|
141
|
+
// Bottom hairline separator.
|
|
142
|
+
`<rect x="0" y="${VB_Y + REF_H - 0.5}" width="${iw}" height="0.5" fill="${border}"/>`,
|
|
143
|
+
// Pill backgrounds
|
|
144
|
+
pill(SIDEBAR_PILL.x, SIDEBAR_PILL.w),
|
|
145
|
+
pill(NAV_PILL.x, NAV_PILL.w),
|
|
146
|
+
pill(pillX, pillW),
|
|
147
|
+
pill(RIGHT_PILL.x + dx, RIGHT_PILL.w),
|
|
148
|
+
// Glyphs
|
|
149
|
+
trafficLights,
|
|
150
|
+
leftLayer,
|
|
151
|
+
emitGlyph(readerGlyph, isDark, pillX - REF_URL_PILL.x),
|
|
152
|
+
emitGlyph(reloadGlyph, isDark, pillRight - (REF_URL_PILL.x + REF_URL_PILL.w)),
|
|
153
|
+
rightLayer,
|
|
154
|
+
// URL text (centered in the address pill)
|
|
155
|
+
`<text x="${r(pillX + pillW / 2)}" y="${cy}" font-family="${FF}" font-size="14" font-weight="510" fill="${urlColor}" text-anchor="middle" dominant-baseline="central">${escText(url)}</text>`,
|
|
156
|
+
].join('\n');
|
|
157
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${svgW}" height="${svgH}" viewBox="0 ${VB_Y} ${iw} ${REF_H}" preserveAspectRatio="none" fill="none">
|
|
158
|
+
${body}
|
|
159
|
+
</svg>`;
|
|
69
160
|
}
|
|
70
161
|
// ── Generator (HTML, for Playwright client preview) ──────────────────────
|
|
71
162
|
export function generateSafariBrowserBarHtml(options) {
|
|
72
163
|
const { config, width, height, pixelScale = 1 } = options;
|
|
73
164
|
const url = cleanUrl(config.url);
|
|
74
165
|
const isDark = config.colorScheme === 'dark';
|
|
75
|
-
const inner = buildSafariSvgInner(url, isDark);
|
|
76
|
-
// The asset's reference is 1299×88. We render it at the target width×height
|
|
77
|
-
// by setting the SVG width/height directly — viewBox handles the scaling.
|
|
78
|
-
// pixelScale lets the server multiply for high-DPI raster output.
|
|
79
166
|
const w = width * pixelScale;
|
|
80
167
|
const h = height * pixelScale;
|
|
81
|
-
const
|
|
82
|
-
return `${FONT_CSS_HTML}<div style="width:${w}px;height:${h}px;position:relative;overflow:hidden;line-height:0;font-size:0"
|
|
83
|
-
<svg width="${w}" height="${h}" viewBox="${vb.x} ${vb.y} ${vb.width} ${vb.height}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" fill="none" style="display:block">${inner}</svg>
|
|
84
|
-
</div>`;
|
|
168
|
+
const svg = buildSafariBarSvg(w, h, isDark, url);
|
|
169
|
+
return `${FONT_CSS_HTML}<div style="width:${w}px;height:${h}px;position:relative;overflow:hidden;line-height:0;font-size:0">${svg}</div>`;
|
|
85
170
|
}
|
|
86
171
|
// ── Generator (SVG, for sharp / resvg server compositing) ────────────────
|
|
87
172
|
export function generateSafariBrowserBarSvg(options) {
|
|
88
173
|
const { config, width, height } = options;
|
|
89
174
|
const url = cleanUrl(config.url);
|
|
90
175
|
const isDark = config.colorScheme === 'dark';
|
|
91
|
-
|
|
92
|
-
const vb = SAFARI_TOOLBAR_VIEWBOX;
|
|
93
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" viewBox="${vb.x} ${vb.y} ${vb.width} ${vb.height}" preserveAspectRatio="none" fill="none">
|
|
94
|
-
${inner}
|
|
95
|
-
</svg>`;
|
|
176
|
+
return buildSafariBarSvg(width, height, isDark, url);
|
|
96
177
|
}
|
|
97
178
|
//# sourceMappingURL=safari-browser-bar.js.map
|
|
Binary file
|