autokap 1.8.9 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -117,6 +117,8 @@ export type ArtifactUploadObjectId = "screenshotRaw" | "screenshot" | "clipGif"
117
117
  export interface ArtifactUploadMetadata {
118
118
  presetId: string;
119
119
  runId: string;
120
+ /** Per-invocation session id grouping every charge of one CLI run (migration 267). */
121
+ sessionId?: string | null;
120
122
  variantId: string;
121
123
  targetId?: string | null;
122
124
  targetLabel?: string | null;
@@ -49,6 +49,13 @@ export interface CLIRunnerOptions {
49
49
  * When `false`, skips the failed-run debug logs export (AUT-149).
50
50
  */
51
51
  exportDebugLogs?: boolean;
52
+ /**
53
+ * Shared invocation id for billing. A multi-preset invocation (`run --outdated`,
54
+ * `auto-recapture`) passes one sessionId for every preset so all their charges
55
+ * group into a single "CLI capture" entry (migration 267). Defaults to the
56
+ * run's own id for a single-preset invocation.
57
+ */
58
+ sessionId?: string;
52
59
  }
53
60
  export interface CLIRunResult {
54
61
  success: boolean;
@@ -156,6 +156,10 @@ export async function runCapture(options) {
156
156
  // here; we seed mockup.ts so the export pipeline can apply them locally.
157
157
  seedDeviceConfigs(program.deviceConfigs ?? null);
158
158
  const runId = randomUUID();
159
+ // A multi-preset invocation shares one sessionId so every preset's charges
160
+ // group into one "CLI capture" billing entry; a lone run groups under its own
161
+ // runId (migration 267).
162
+ const sessionId = options.sessionId ?? runId;
159
163
  let videoAudioAssets;
160
164
  let videoAudioAssetsByLocale;
161
165
  try {
@@ -170,7 +174,7 @@ export async function runCapture(options) {
170
174
  // durations + audio assets are only consumed on the upload path (signalVideoComplete), which a dry
171
175
  // run never reaches, so skipping prep here is side-effect-free for dry.
172
176
  if (!options.dryRun && !options.program && program.mediaMode === 'video') {
173
- const prepareResult = await prepareVideoSpeechForRun(config, options.presetId, runId, options.regenerateTts ?? false);
177
+ const prepareResult = await prepareVideoSpeechForRun(config, options.presetId, runId, options.regenerateTts ?? false, sessionId);
174
178
  if (!prepareResult.success) {
175
179
  return { success: false, runId, error: prepareResult.error };
176
180
  }
@@ -308,7 +312,7 @@ export async function runCapture(options) {
308
312
  message: 'saving captures',
309
313
  });
310
314
  const provenance = buildRunProvenance(program, schemaVersionOrigin);
311
- const uploadOutcome = await uploadResults(config, program, runResult, runId, provenance);
315
+ const uploadOutcome = await uploadResults(config, program, runResult, runId, sessionId, provenance);
312
316
  if (program.mediaMode === 'video' && runResult.success) {
313
317
  await signalVideoComplete(config, program, runResult, uploadOutcome.runId, videoAudioAssets, videoAudioAssetsByLocale);
314
318
  }
@@ -356,7 +360,7 @@ export async function runCapture(options) {
356
360
  && (runResult ? !runResult.success : true);
357
361
  if (shouldExport) {
358
362
  logger.info('[debug-logs] Exporting debug logs to AutoKap…');
359
- await logCollector.flushTo(runId, program.presetId, config.apiBaseUrl, config.apiKey, options.env);
363
+ await logCollector.flushTo(runId, program.presetId, config.apiBaseUrl, config.apiKey, options.env, runResult?.failureKind);
360
364
  }
361
365
  logCollector.stop();
362
366
  }
@@ -430,7 +434,7 @@ async function fetchProgram(config, presetId, environmentName) {
430
434
  }
431
435
  return { success: false, error: 'failed to fetch program: retry attempts exhausted' };
432
436
  }
433
- async function prepareVideoSpeechForRun(config, videoId, runId, regenerateTts) {
437
+ async function prepareVideoSpeechForRun(config, videoId, runId, regenerateTts, sessionId) {
434
438
  if (regenerateTts) {
435
439
  logger.info('[capture] Forcing TTS regeneration — all cached segments will be re-synthesized and billed.');
436
440
  }
@@ -445,7 +449,9 @@ async function prepareVideoSpeechForRun(config, videoId, runId, regenerateTts) {
445
449
  'Content-Type': 'application/json',
446
450
  [CLI_VERSION_HEADER]: APP_VERSION,
447
451
  },
448
- body: JSON.stringify(regenerateTts ? { videoId, runId, regenerateTts: true } : { videoId, runId }),
452
+ body: JSON.stringify(regenerateTts
453
+ ? { videoId, runId, sessionId, regenerateTts: true }
454
+ : { videoId, runId, sessionId }),
449
455
  });
450
456
  }
451
457
  catch (err) {
@@ -686,7 +692,7 @@ async function postRunStart(config, runId, presetId, variantCount, env) {
686
692
  logger.warn(`[capture] Run registration error: ${message}`);
687
693
  }
688
694
  }
689
- async function uploadResults(config, program, result, runId, provenance) {
695
+ async function uploadResults(config, program, result, runId, sessionId, provenance) {
690
696
  const artifactJobs = result.variantResults.flatMap((variant) => {
691
697
  const variantSpec = program.variants.find((entry) => entry.id === variant.variantId);
692
698
  return variant.artifacts.map((artifact) => ({
@@ -701,7 +707,7 @@ async function uploadResults(config, program, result, runId, provenance) {
701
707
  logger.info(`[capture] Uploading ${totalArtifacts} capture artifacts with concurrency ${artifactUploadConcurrency}`);
702
708
  }
703
709
  await runWithConcurrency(artifactJobs, artifactUploadConcurrency, async (job, index) => {
704
- await uploadArtifact(config, program, runId, totalArtifacts, index + 1, job, provenance);
710
+ await uploadArtifact(config, program, runId, sessionId, totalArtifacts, index + 1, job, provenance);
705
711
  });
706
712
  // Strip binary buffers from artifacts before sending. The raw PNG/video
707
713
  // buffers were already uploaded via /api/cli/artifacts above, and the
@@ -862,18 +868,19 @@ function inferVariantLocale(variantId) {
862
868
  function inferVariantTheme(variantId) {
863
869
  return variantId.endsWith('-dark') ? 'dark' : 'light';
864
870
  }
865
- async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumber, job, provenance) {
871
+ async function uploadArtifact(config, program, runId, sessionId, totalArtifacts, uploadNumber, job, provenance) {
866
872
  const { artifact, variant, variantSpec } = job;
867
873
  const filename = buildArtifactFilename(program.presetId, variant.variantId, artifact);
868
874
  const label = artifact.captureName ?? artifact.clipName ?? filename;
869
875
  logger.info(`[capture] Exporting capture ${uploadNumber}/${totalArtifacts}: ${label}`);
870
876
  if (process.env.AUTOKAP_USE_LEGACY_MULTIPART_UPLOADS === '1') {
871
- await uploadArtifactMultipart(config, program, runId, job, filename, provenance);
877
+ await uploadArtifactMultipart(config, program, runId, sessionId, job, filename, provenance);
872
878
  return;
873
879
  }
874
880
  const prepared = await prepareDirectArtifactUpload({
875
881
  program,
876
882
  runId,
883
+ sessionId,
877
884
  artifact,
878
885
  variant,
879
886
  variantSpec,
@@ -938,7 +945,7 @@ async function uploadArtifact(config, program, runId, totalArtifacts, uploadNumb
938
945
  throw new Error(`artifact completion failed for ${variant.variantId}: ${await formatServerError(completeResponse, completeUrl)}`);
939
946
  }
940
947
  }
941
- async function uploadArtifactMultipart(config, program, runId, job, filename, provenance) {
948
+ async function uploadArtifactMultipart(config, program, runId, sessionId, job, filename, provenance) {
942
949
  const { artifact, variant, variantSpec } = job;
943
950
  const formData = new FormData();
944
951
  formData.append('file', new Blob([new Uint8Array(artifact.buffer)], { type: artifact.mimeType }), filename);
@@ -952,6 +959,7 @@ async function uploadArtifactMultipart(config, program, runId, job, filename, pr
952
959
  formData.append('cliVersion', provenance.cliVersion);
953
960
  formData.append('programHash', provenance.programHash);
954
961
  formData.append('runId', runId);
962
+ formData.append('sessionId', sessionId);
955
963
  formData.append('variantId', variant.variantId);
956
964
  formData.append('targetId', variantSpec?.targetId ?? variant.variantId);
957
965
  formData.append('targetLabel', variantSpec?.targetLabel ?? variantSpec?.deviceFrame ?? variant.variantId);
@@ -1023,7 +1031,7 @@ async function uploadArtifactMultipart(config, program, runId, job, filename, pr
1023
1031
  }
1024
1032
  }
1025
1033
  async function prepareDirectArtifactUpload(params) {
1026
- const { program, runId, artifact, variant, variantSpec, provenance } = params;
1034
+ const { program, runId, sessionId, artifact, variant, variantSpec, provenance } = params;
1027
1035
  const requestedDeviceScaleFactor = variantSpec?.deviceScaleFactor ?? program.outputScale ?? 2;
1028
1036
  const isFrameCapture = artifact.mediaMode === 'clip' || artifact.mediaMode === 'video';
1029
1037
  const deviceScaleFactor = isFrameCapture && Number.isFinite(requestedDeviceScaleFactor)
@@ -1040,6 +1048,7 @@ async function prepareDirectArtifactUpload(params) {
1040
1048
  cliVersion: provenance.cliVersion,
1041
1049
  programHash: provenance.programHash,
1042
1050
  runId,
1051
+ sessionId,
1043
1052
  variantId: variant.variantId,
1044
1053
  targetId: variantSpec?.targetId ?? variant.variantId,
1045
1054
  targetLabel: variantSpec?.targetLabel ?? variantSpec?.deviceFrame ?? variant.variantId,
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import { createRequire } from 'node:module';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import path from 'node:path';
5
6
  import fs from 'node:fs/promises';
6
7
  const require = createRequire(import.meta.url);
@@ -137,6 +138,9 @@ async function runOutdatedPresetsLocally(opts) {
137
138
  const { runCapture } = await import('./cli-runner.js');
138
139
  const failures = [];
139
140
  logger.info(`[capture] Running ${data.presets.length} outdated preset(s)`);
141
+ // One session id for the whole invocation so every preset's screenshot/clip/
142
+ // video charges group into a single "CLI capture" billing entry (migration 267).
143
+ const sessionId = randomUUID();
140
144
  for (const preset of data.presets) {
141
145
  const label = preset.name ? `${preset.name} (${preset.id})` : preset.id;
142
146
  logger.info(`[capture] Running outdated preset ${label}`);
@@ -147,6 +151,7 @@ async function runOutdatedPresetsLocally(opts) {
147
151
  allowUploadFailure: opts.allowUploadFailure,
148
152
  dryRun: opts.dry,
149
153
  regenerateTts: opts.regenerateTts,
154
+ sessionId,
150
155
  });
151
156
  if (!result.success) {
152
157
  failures.push({
@@ -397,6 +402,10 @@ program
397
402
  failedPresets: 0,
398
403
  message: `Runner started: ${data.presets.length} preset(s) to capture`,
399
404
  });
405
+ // One session id for the whole invocation so every preset's charges group
406
+ // into a single "CLI capture" billing entry (migration 267). For cloud runs
407
+ // child artifact billing is suppressed server-side, so this is a no-op there.
408
+ const sessionId = randomUUID();
400
409
  for (const [index, preset] of data.presets.entries()) {
401
410
  const presetDisplayName = displayPresetName(preset);
402
411
  const label = preset.name ? `${preset.name} (${preset.id})` : preset.id;
@@ -417,6 +426,7 @@ program
417
426
  headed: opts.headed,
418
427
  allowUploadFailure: opts.allowUploadFailure,
419
428
  regenerateTts: opts.regenerateTts,
429
+ sessionId,
420
430
  // Each preset runs under its own ephemeral runId, which is NOT a
421
431
  // capture_runs row in a cloud batch (the parent cloud run owns the row),
422
432
  // so the per-preset error-log export would 404. Failure telemetry for
@@ -457,6 +467,9 @@ program
457
467
  childRunId,
458
468
  status: 'failed',
459
469
  errorMessage: error,
470
+ ...(result.runResult?.failureKind
471
+ ? { failureKind: result.runResult.failureKind }
472
+ : {}),
460
473
  message: `Preset failed: ${presetDisplayName}`,
461
474
  });
462
475
  }
@@ -654,6 +654,13 @@ export interface OpcodeResult {
654
654
  /** Error message if failed */
655
655
  error?: string;
656
656
  }
657
+ /**
658
+ * Structured failure category, set on top of the free-text `error`. Lets the
659
+ * server surface a specific preset state instead of a generic "failed":
660
+ * `login_failed` = an opcode inside the login window (credential typing → first
661
+ * post-login assertion) failed, so the credentials are likely wrong.
662
+ */
663
+ export type RunFailureKind = 'login_failed';
657
664
  export interface VariantResult {
658
665
  variantId: string;
659
666
  success: boolean;
@@ -670,6 +677,8 @@ export interface VariantResult {
670
677
  */
671
678
  detectedAppVersion?: string | null;
672
679
  error?: string;
680
+ /** Set when the failure falls inside the login window — see RunFailureKind. */
681
+ failureKind?: RunFailureKind;
673
682
  }
674
683
  export interface ArtifactResult {
675
684
  mediaMode: MediaMode;
@@ -821,6 +830,8 @@ export interface RunResult {
821
830
  */
822
831
  warnings?: string[];
823
832
  error?: string;
833
+ /** First non-null variant `failureKind` — see RunFailureKind. */
834
+ failureKind?: RunFailureKind;
824
835
  }
825
836
  export interface WaitCondition {
826
837
  selector: string;
@@ -1,5 +1,6 @@
1
1
  import type { LogEntry } from './logger.js';
2
2
  import type { ProgressEvent } from './opcode-runner.js';
3
+ import type { RunFailureKind } from './execution-types.js';
3
4
  export interface OpcodeContext {
4
5
  index: number;
5
6
  kind: string;
@@ -24,6 +25,9 @@ export interface ErrorLogsPayload {
24
25
  entries: ExportedLogEntry[];
25
26
  envName?: string;
26
27
  endedAt: string;
28
+ /** Structured failure category (e.g. 'login_failed'). Lets the server tag the
29
+ * preset's capture_failed event with a specific kind. */
30
+ failureKind?: RunFailureKind;
27
31
  }
28
32
  export declare class LogCollector {
29
33
  private entries;
@@ -35,7 +39,7 @@ export declare class LogCollector {
35
39
  onProgress(event: ProgressEvent): void;
36
40
  snapshot(): ExportedLogEntry[];
37
41
  size(): number;
38
- flushTo(runId: string, presetId: string, apiBaseUrl: string, apiKey: string, envName?: string): Promise<{
42
+ flushTo(runId: string, presetId: string, apiBaseUrl: string, apiKey: string, envName?: string, failureKind?: RunFailureKind): Promise<{
39
43
  ok: boolean;
40
44
  status?: number;
41
45
  error?: string;
@@ -61,7 +61,7 @@ export class LogCollector {
61
61
  size() {
62
62
  return this.entries.length;
63
63
  }
64
- async flushTo(runId, presetId, apiBaseUrl, apiKey, envName) {
64
+ async flushTo(runId, presetId, apiBaseUrl, apiKey, envName, failureKind) {
65
65
  if (this.entries.length === 0) {
66
66
  return { ok: true, status: 204 };
67
67
  }
@@ -71,6 +71,7 @@ export class LogCollector {
71
71
  entries: this.entries,
72
72
  envName,
73
73
  endedAt: new Date().toISOString(),
74
+ ...(failureKind ? { failureKind } : {}),
74
75
  };
75
76
  const controller = new AbortController();
76
77
  const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Login detection — pure, dependency-light helpers shared by the opcode runner
3
+ * (to classify a failure as login-related) and credential substitution
4
+ * (opcode-actions.ts owns the actual `{{token}}` replacement).
5
+ *
6
+ * A program "logs in" when it types credentials: a TYPE opcode whose text (or a
7
+ * locale override) contains the `{{email}}` / `{{password}}` placeholder. The
8
+ * server resolves these placeholders at capture time from the preset's linked
9
+ * credentials account; the stored program only ever holds the placeholders.
10
+ *
11
+ * This module imports ONLY types so it stays safe to reference from any layer.
12
+ */
13
+ import type { ExecutionOpcode } from './execution-types.js';
14
+ /** The credential placeholder tokens — the contract between the authored
15
+ * program and the server-side substitution. Single source of truth. */
16
+ export declare const CREDENTIAL_TOKEN_EMAIL = "{{email}}";
17
+ export declare const CREDENTIAL_TOKEN_PASSWORD = "{{password}}";
18
+ export declare const CREDENTIAL_TOKEN_LOGIN_URL = "{{loginUrl}}";
19
+ /**
20
+ * Which credential fields the program actually requires. Lets a caller compare
21
+ * against the linked account's available fields (has_email / has_password)
22
+ * without decrypting anything.
23
+ */
24
+ export declare function programRequiredCredentialFields(steps: ExecutionOpcode[]): {
25
+ email: boolean;
26
+ password: boolean;
27
+ };
28
+ /** True when the program logs in (types an email or password). */
29
+ export declare function programRequiresLogin(steps: ExecutionOpcode[]): boolean;
30
+ /**
31
+ * The contiguous index range that constitutes the login flow:
32
+ * - `start` = first credential-typing opcode,
33
+ * - `end` = first post-login assertion after the last credential opcode —
34
+ * EITHER a standalone assertion opcode (ASSERT_ROUTE /
35
+ * ASSERT_SURFACE / WAIT_FOR) OR, as the generator actually emits,
36
+ * the submit CLICK whose own postcondition asserts the post-login
37
+ * route/element (route_matches / element_visible) — else the last
38
+ * credential opcode itself.
39
+ *
40
+ * Scanning stops at that FIRST asserting opcode, so the window covers the
41
+ * credential typing and the submit (which proves login) but does NOT extend
42
+ * across the post-login navigation. A failure anywhere in `[start, end]` means
43
+ * the login did not go through: a TYPE failing (form gone/changed), the submit
44
+ * failing, or its post-login assertion failing (still on /login). Returns null
45
+ * when the program does not log in.
46
+ */
47
+ export declare function getLoginWindow(steps: ExecutionOpcode[]): {
48
+ start: number;
49
+ end: number;
50
+ } | null;
51
+ /** True when a failure at `failedIndex` falls inside the login window. */
52
+ export declare function isLoginFailureIndex(steps: ExecutionOpcode[], failedIndex: number): boolean;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Login detection — pure, dependency-light helpers shared by the opcode runner
3
+ * (to classify a failure as login-related) and credential substitution
4
+ * (opcode-actions.ts owns the actual `{{token}}` replacement).
5
+ *
6
+ * A program "logs in" when it types credentials: a TYPE opcode whose text (or a
7
+ * locale override) contains the `{{email}}` / `{{password}}` placeholder. The
8
+ * server resolves these placeholders at capture time from the preset's linked
9
+ * credentials account; the stored program only ever holds the placeholders.
10
+ *
11
+ * This module imports ONLY types so it stays safe to reference from any layer.
12
+ */
13
+ /** The credential placeholder tokens — the contract between the authored
14
+ * program and the server-side substitution. Single source of truth. */
15
+ export const CREDENTIAL_TOKEN_EMAIL = '{{email}}';
16
+ export const CREDENTIAL_TOKEN_PASSWORD = '{{password}}';
17
+ export const CREDENTIAL_TOKEN_LOGIN_URL = '{{loginUrl}}';
18
+ /** Opcode kinds that prove a login advanced past the form: an explicit route /
19
+ * surface assertion, or a wait for a post-login element. The first such opcode
20
+ * after the credential typing closes the "login window". */
21
+ const POSTLOGIN_ASSERTION_KINDS = new Set([
22
+ 'ASSERT_ROUTE',
23
+ 'ASSERT_SURFACE',
24
+ 'WAIT_FOR',
25
+ ]);
26
+ /** Postcondition types that assert the login advanced past the form. The
27
+ * canonical generated login flow does NOT emit a standalone assertion opcode —
28
+ * it encodes the post-login check on the submit CLICK's own postcondition
29
+ * (route_matches the post-login route, or element_visible a post-login
30
+ * element). The first opcode after the credentials whose postcondition is one
31
+ * of these closes the window too, so the submit click is inside it. */
32
+ const POSTLOGIN_POSTCONDITION_TYPES = new Set([
33
+ 'route_matches',
34
+ 'element_visible',
35
+ ]);
36
+ /** Every text field of an opcode that may carry a credential placeholder. */
37
+ function credentialTexts(opcode) {
38
+ if (opcode.kind === 'TYPE') {
39
+ const texts = [opcode.text];
40
+ if (opcode.textByLocale)
41
+ texts.push(...Object.values(opcode.textByLocale));
42
+ return texts;
43
+ }
44
+ if (opcode.kind === 'NAVIGATE')
45
+ return [opcode.url];
46
+ return [];
47
+ }
48
+ /** True when the opcode types/uses email or password credentials. */
49
+ function isCredentialOpcode(opcode) {
50
+ return credentialTexts(opcode).some((text) => typeof text === 'string' &&
51
+ (text.includes(CREDENTIAL_TOKEN_EMAIL) || text.includes(CREDENTIAL_TOKEN_PASSWORD)));
52
+ }
53
+ /**
54
+ * Which credential fields the program actually requires. Lets a caller compare
55
+ * against the linked account's available fields (has_email / has_password)
56
+ * without decrypting anything.
57
+ */
58
+ export function programRequiredCredentialFields(steps) {
59
+ let email = false;
60
+ let password = false;
61
+ for (const opcode of steps) {
62
+ for (const text of credentialTexts(opcode)) {
63
+ if (typeof text !== 'string')
64
+ continue;
65
+ if (text.includes(CREDENTIAL_TOKEN_EMAIL))
66
+ email = true;
67
+ if (text.includes(CREDENTIAL_TOKEN_PASSWORD))
68
+ password = true;
69
+ }
70
+ if (email && password)
71
+ break;
72
+ }
73
+ return { email, password };
74
+ }
75
+ /** True when the program logs in (types an email or password). */
76
+ export function programRequiresLogin(steps) {
77
+ const { email, password } = programRequiredCredentialFields(steps);
78
+ return email || password;
79
+ }
80
+ /**
81
+ * The contiguous index range that constitutes the login flow:
82
+ * - `start` = first credential-typing opcode,
83
+ * - `end` = first post-login assertion after the last credential opcode —
84
+ * EITHER a standalone assertion opcode (ASSERT_ROUTE /
85
+ * ASSERT_SURFACE / WAIT_FOR) OR, as the generator actually emits,
86
+ * the submit CLICK whose own postcondition asserts the post-login
87
+ * route/element (route_matches / element_visible) — else the last
88
+ * credential opcode itself.
89
+ *
90
+ * Scanning stops at that FIRST asserting opcode, so the window covers the
91
+ * credential typing and the submit (which proves login) but does NOT extend
92
+ * across the post-login navigation. A failure anywhere in `[start, end]` means
93
+ * the login did not go through: a TYPE failing (form gone/changed), the submit
94
+ * failing, or its post-login assertion failing (still on /login). Returns null
95
+ * when the program does not log in.
96
+ */
97
+ export function getLoginWindow(steps) {
98
+ let start = -1;
99
+ let lastCred = -1;
100
+ for (let i = 0; i < steps.length; i++) {
101
+ if (isCredentialOpcode(steps[i])) {
102
+ if (start < 0)
103
+ start = i;
104
+ lastCred = i;
105
+ }
106
+ }
107
+ if (start < 0)
108
+ return null;
109
+ let end = lastCred;
110
+ for (let i = lastCred + 1; i < steps.length; i++) {
111
+ const step = steps[i];
112
+ if (POSTLOGIN_ASSERTION_KINDS.has(step.kind) ||
113
+ (step.postcondition != null &&
114
+ POSTLOGIN_POSTCONDITION_TYPES.has(step.postcondition.type))) {
115
+ end = i;
116
+ break;
117
+ }
118
+ }
119
+ return { start, end };
120
+ }
121
+ /** True when a failure at `failedIndex` falls inside the login window. */
122
+ export function isLoginFailureIndex(steps, failedIndex) {
123
+ const window = getLoginWindow(steps);
124
+ return window !== null && failedIndex >= window.start && failedIndex <= window.end;
125
+ }
126
+ //# sourceMappingURL=login-detection.js.map
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { VARIANT_PLACEHOLDER } from './execution-types.js';
8
8
  import { dismissAllOverlays } from './overlay-engine.js';
9
+ import { CREDENTIAL_TOKEN_EMAIL, CREDENTIAL_TOKEN_PASSWORD, CREDENTIAL_TOKEN_LOGIN_URL, } from './login-detection.js';
9
10
  /**
10
11
  * Substitute credential placeholders inside opcode text fields.
11
12
  * Only the {{email}}, {{password}} and {{loginUrl}} tokens are replaced.
@@ -16,9 +17,9 @@ export function substituteCredentialPlaceholders(text, credentials) {
16
17
  return text;
17
18
  }
18
19
  return text
19
- .replaceAll('{{email}}', credentials?.email ?? '')
20
- .replaceAll('{{password}}', credentials?.password ?? '')
21
- .replaceAll('{{loginUrl}}', credentials?.loginUrl ?? '');
20
+ .replaceAll(CREDENTIAL_TOKEN_EMAIL, credentials?.email ?? '')
21
+ .replaceAll(CREDENTIAL_TOKEN_PASSWORD, credentials?.password ?? '')
22
+ .replaceAll(CREDENTIAL_TOKEN_LOGIN_URL, credentials?.loginUrl ?? '');
22
23
  }
23
24
  /**
24
25
  * Returns the list of credential placeholders (`{{email}}`, `{{password}}`,
@@ -32,14 +33,14 @@ export function findUnresolvedCredentialPlaceholders(text, credentials) {
32
33
  if (typeof text !== 'string' || !text.includes('{{'))
33
34
  return [];
34
35
  const missing = [];
35
- if (text.includes('{{email}}') && !credentials?.email?.trim()) {
36
- missing.push('{{email}}');
36
+ if (text.includes(CREDENTIAL_TOKEN_EMAIL) && !credentials?.email?.trim()) {
37
+ missing.push(CREDENTIAL_TOKEN_EMAIL);
37
38
  }
38
- if (text.includes('{{password}}') && !credentials?.password) {
39
- missing.push('{{password}}');
39
+ if (text.includes(CREDENTIAL_TOKEN_PASSWORD) && !credentials?.password) {
40
+ missing.push(CREDENTIAL_TOKEN_PASSWORD);
40
41
  }
41
- if (text.includes('{{loginUrl}}') && !credentials?.loginUrl?.trim()) {
42
- missing.push('{{loginUrl}}');
42
+ if (text.includes(CREDENTIAL_TOKEN_LOGIN_URL) && !credentials?.loginUrl?.trim()) {
43
+ missing.push(CREDENTIAL_TOKEN_LOGIN_URL);
43
44
  }
44
45
  return missing;
45
46
  }
@@ -14,6 +14,7 @@ import { smartWaitForStability } from './smart-wait.js';
14
14
  import { verifyCaptureQuality } from './capture-verification.js';
15
15
  import { generateAltText } from './alt-text.js';
16
16
  import { executeOpcodeCoreAction } from './opcode-actions.js';
17
+ import { isLoginFailureIndex } from './login-detection.js';
17
18
  import { logger } from './logger.js';
18
19
  function formatOpcodeDebug(opcode) {
19
20
  const fields = [];
@@ -172,6 +173,7 @@ export async function executeProgram(program, createAdapter, options = {}) {
172
173
  detectedAppVersion,
173
174
  warnings: aggregatedWarnings.length ? aggregatedWarnings : undefined,
174
175
  error: aborted ? 'aborted' : (success ? undefined : completedVariantResults.find(v => !v.success)?.error),
176
+ failureKind: success ? undefined : completedVariantResults.find(v => v.failureKind)?.failureKind,
175
177
  };
176
178
  }
177
179
  // ── Variant execution ───────────────────────────────────────────────
@@ -247,6 +249,12 @@ async function executeVariant(program, variant, createAdapter, recoveryChain, te
247
249
  durationMs: Date.now() - startTime,
248
250
  artifacts,
249
251
  error: `opcode ${i} (${opcode.kind}) failed: ${result.error}`,
252
+ // Tag failures inside the login window (credential typing → first
253
+ // post-login assertion) so the server can surface "login failed"
254
+ // instead of a generic capture failure.
255
+ ...(isLoginFailureIndex(program.steps, i)
256
+ ? { failureKind: 'login_failed' }
257
+ : {}),
250
258
  };
251
259
  }
252
260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autokap",
3
- "version": "1.8.9",
3
+ "version": "1.9.0",
4
4
  "description": "AI-powered CLI tool for capturing clean screenshots of websites",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",