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.
- package/dist/cli-contract.d.ts +2 -0
- package/dist/cli-runner.d.ts +7 -0
- package/dist/cli-runner.js +20 -11
- package/dist/cli.js +13 -0
- package/dist/execution-types.d.ts +11 -0
- package/dist/log-collector.d.ts +5 -1
- package/dist/log-collector.js +2 -1
- package/dist/login-detection.d.ts +52 -0
- package/dist/login-detection.js +126 -0
- package/dist/opcode-actions.js +10 -9
- package/dist/opcode-runner.js +8 -0
- package/package.json +1 -1
package/dist/cli-contract.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli-runner.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli-runner.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/log-collector.d.ts
CHANGED
|
@@ -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;
|
package/dist/log-collector.js
CHANGED
|
@@ -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
|
package/dist/opcode-actions.js
CHANGED
|
@@ -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(
|
|
20
|
-
.replaceAll(
|
|
21
|
-
.replaceAll(
|
|
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(
|
|
36
|
-
missing.push(
|
|
36
|
+
if (text.includes(CREDENTIAL_TOKEN_EMAIL) && !credentials?.email?.trim()) {
|
|
37
|
+
missing.push(CREDENTIAL_TOKEN_EMAIL);
|
|
37
38
|
}
|
|
38
|
-
if (text.includes(
|
|
39
|
-
missing.push(
|
|
39
|
+
if (text.includes(CREDENTIAL_TOKEN_PASSWORD) && !credentials?.password) {
|
|
40
|
+
missing.push(CREDENTIAL_TOKEN_PASSWORD);
|
|
40
41
|
}
|
|
41
|
-
if (text.includes(
|
|
42
|
-
missing.push(
|
|
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
|
}
|
package/dist/opcode-runner.js
CHANGED
|
@@ -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
|
}
|