@appliqation/autotest 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Appliqation Pty Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Appliqation Autotest
2
+
3
+ **Autonomously executes a test case in a real browser, then has a second, independent AI judge the result from evidence alone — never from the first agent's own claim.**
4
+
5
+ Point it at one test case, a whole scenario, or a whole test set (regression/sanity/smoke — the most common CI shape), and it drives a real Playwright browser, captures real evidence (screenshots, console/network logs, accessibility snapshots), and writes an honest, appq-polled verdict back to Appliqation. No fabricated pass/fail — a validator that can't confirm something reports `blocked`, not a guess.
6
+
7
+ ## Why two agents, not one
8
+
9
+ A single model that both executes a test and grades its own execution is grading its own homework. This repo genuinely separates the two roles — **executor** and **validator** each run as their own fresh, isolated tool-calling loop with no shared context between them. The validator never sees the executor's reasoning, only what it explicitly submitted as evidence via `submit_execution_evidence`. That's the entire mechanism behind trustworthy self-verification here: isolation, not a prompt asking the model to "be objective."
10
+
11
+ ## How it works
12
+
13
+ ```mermaid
14
+ sequenceDiagram
15
+ participant E as Executor
16
+ participant B as Real Browser
17
+ participant Ev as Evidence Store
18
+ participant V as Validator
19
+ participant A as Appliqation
20
+
21
+ E->>B: drive the test steps
22
+ B-->>E: screenshots, console/network, DOM
23
+ E->>Ev: submit_execution_evidence
24
+ Note over E,V: fresh context — no shared conversation
25
+ V->>Ev: read only the submitted evidence
26
+ V->>V: judge each step: met / not_met / blocked
27
+ V->>A: write the real verdict (update_run_results)
28
+ A-->>V: authoritative run status
29
+ ```
30
+
31
+ - **Verdicts are polled, not parsed.** The final status comes from Appliqation's own run matrix (`get_test_results`), not scraped out of the validator's report prose.
32
+ - **A destructive-action gate** blocks any click matching a destructive-verb/`mailto:`/`tel:`/`sms:` pattern before it ever dispatches — checked in code, not left to the model to notice.
33
+ - **Per-TC role inference.** Mixed-role scenarios (admin sees X, standard user gets 403 on the same page) get the right authenticated session per test case automatically, from each TC's own tag or name — no manual per-run role juggling.
34
+
35
+ ## Quick start
36
+
37
+ ```bash
38
+ npm install -g @appliqation/autotest
39
+ npx playwright install chromium
40
+ ```
41
+
42
+ Create a `.env` file (in whatever directory you'll run it from) with:
43
+
44
+ ```
45
+ APPQ_API_KEY=your-appliqation-api-key
46
+ ANTHROPIC_API_KEY=your-anthropic-key # or OPENAI_API_KEY — pick one
47
+ ```
48
+
49
+ ```bash
50
+ # one test case
51
+ appliqation-autotest judge --test-case-uuid <uuid> --environment Stage --dry-run
52
+
53
+ # a whole test set (regression / sanity / smoke — the common CI shape)
54
+ appliqation-autotest judge --test-set-id <id> --environment Stage --dry-run
55
+ ```
56
+
57
+ `--dry-run` is the recommended default for your first run against a real project — it computes real verdicts but suppresses the actual Appliqation writeback. Drop it once you trust the result. `--coverage` (`always` / `on-script-absence` / `sampled:N` / `external`) controls when this agentic pass runs alongside your existing deterministic Playwright pipeline in scenario/test-set mode; `--json`/`--ci` give a structured summary and a CI-friendly exit code.
58
+
59
+ ## Configuration
60
+
61
+ Copy `.env.example` to `.env`. Requires `APPQ_API_KEY` and one of `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`. Separate executor/validator model overrides are supported — a cheaper model for judging captured evidence is a reasonable choice even when the executor needs a stronger one for open-ended browsing.
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ git clone https://github.com/appliqation/appliqation-autotest.git
67
+ cd appliqation-autotest
68
+ npm install
69
+ cp .env.example .env # fill in APPQ_API_KEY and one LLM provider key
70
+ npm run dev -- judge --test-case-uuid <uuid> --environment <name>
71
+ npm run typecheck
72
+ npm test
73
+ ```
74
+
75
+ See `CLAUDE.md` for a map of this repo if you're working in it with an AI coding assistant.
76
+
77
+ ## License
78
+
79
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,24 @@
1
+ // Extracted out of cli/index.ts so this is testable without triggering that
2
+ // file's top-level program.parseAsync(process.argv) side effect — same
3
+ // reasoning as this repo's own cli/resolvers.ts extraction.
4
+ //
5
+ // One record per `judge` invocation regardless of mode (single-TC/whole-
6
+ // scenario/test-set) — all three converge on the same RunSummary shape.
7
+ // No turns/budgetExceeded at the top level: those are per-TC internals of
8
+ // judgeTc()'s executor/validator pair, not something RunSummary aggregates.
9
+ import { safeRecord } from '@appliqation/agent-core';
10
+ export async function recordJudgeRun(args) {
11
+ const { sink, startedAt, endedAt, executorModel, validatorModel, usage, exitCode, summary } = args;
12
+ await safeRecord(sink, {
13
+ agent: 'appliqation-autotest',
14
+ subcommand: 'judge',
15
+ startedAt,
16
+ endedAt,
17
+ durationMillis: endedAt - startedAt,
18
+ model: `executor:${executorModel} validator:${validatorModel}`,
19
+ usage,
20
+ exitCode,
21
+ outcome: summary ? { ...summary } : { results: [], note: 'no test cases found' },
22
+ });
23
+ }
24
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../../src/cli/audit.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,uEAAuE;AACvE,4DAA4D;AAC5D,EAAE;AACF,yEAAyE;AACzE,wEAAwE;AACxE,0EAA0E;AAC1E,4EAA4E;AAE5E,OAAO,EAAE,UAAU,EAAoC,MAAM,yBAAyB,CAAC;AAevF,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAwB;IAC3D,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IACnG,MAAM,UAAU,CAAC,IAAI,EAAE;QACrB,KAAK,EAAE,sBAAsB;QAC7B,UAAU,EAAE,OAAO;QACnB,SAAS;QACT,OAAO;QACP,cAAc,EAAE,OAAO,GAAG,SAAS;QACnC,KAAK,EAAE,YAAY,aAAa,cAAc,cAAc,EAAE;QAC9D,KAAK;QACL,QAAQ;QACR,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,qBAAqB,EAAE;KACjF,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env node
2
+ // `judge`: the two-stage executor/validator pattern for one TC, against the
3
+ // real appq:autotest-* workflows — also applied across a whole scenario or
4
+ // test set, with a coverage policy deciding per TC whether agentic coverage
5
+ // runs alongside whatever the deterministic pipeline already does
6
+ // automatically. Open-ended exploratory QA (the former `runman` command,
7
+ // Phase 1's engine proof) has moved to its own dedicated agent,
8
+ // appliqation-explorer — see that repo's CLAUDE.md.
9
+ import { Command } from 'commander';
10
+ import { createMcpClient, createAnthropicAdapter, createOpenAiAdapter, createUsageAccumulator, resolveStorageState, resolveApiAuth, knownRolesForProject, inferRole, isApiTest, resolveRun, resolveScenarioId, fetchScenarioInfo, fetchTestSetInfo, scenarioIdFromTcUuid, resolveUrl, } from '@appliqation/agent-core';
11
+ import { config, resolveProvider, resolveModel } from '../config/env.js';
12
+ import { judgeTc } from '../orchestrator/judgeTc.js';
13
+ import { parseCoveragePolicy, shouldRunAgenticCoverage } from '../orchestrator/coveragePolicy.js';
14
+ import { pollTestResults } from '../orchestrator/pollResults.js';
15
+ import { recordJudgeRun } from './audit.js';
16
+ import { printJsonSummary, printHumanSummary, exitCodeFor } from './output.js';
17
+ const client = createMcpClient({ origin: config.appqOrigin, apiKey: config.appqApiKey() });
18
+ /** Builds the adapter for a given role — see resolveModel() for why executor/validator can differ. */
19
+ function buildAdapter(role) {
20
+ const provider = resolveProvider();
21
+ const model = resolveModel(role);
22
+ return provider === 'anthropic'
23
+ ? createAnthropicAdapter(config.anthropicApiKey, model, config.anthropicMaxTokens)
24
+ : createOpenAiAdapter(config.openaiApiKey, model, config.openaiMaxOutputTokens);
25
+ }
26
+ function logEvent(prefix, onUsage) {
27
+ return (e) => {
28
+ if (e.type === 'assistant') {
29
+ const text = (e.detail ?? '').trim();
30
+ // Many tool-calling turns return no accompanying text — nothing to show.
31
+ if (text)
32
+ console.error(`${prefix}[thinking] ${text}`);
33
+ }
34
+ else if (e.type === 'tool') {
35
+ const d = e.detail;
36
+ console.error(`${prefix}[tool] ${d.name} -> ${d.result.slice(0, 200)}`);
37
+ }
38
+ else if (e.type === 'log') {
39
+ console.error(`${prefix}[log] ${e.detail}`);
40
+ }
41
+ else if (e.type === 'usage') {
42
+ const u = e.detail;
43
+ onUsage?.(u);
44
+ const cacheNote = u.cacheReadTokens
45
+ ? ` (${u.cacheReadTokens} from cache)`
46
+ : u.cacheWriteTokens
47
+ ? ` (${u.cacheWriteTokens} written to cache)`
48
+ : '';
49
+ console.error(`${prefix}[usage] in=${u.inputTokens} out=${u.outputTokens}${cacheNote}`);
50
+ }
51
+ };
52
+ }
53
+ function printResult(label, result) {
54
+ console.log(`\n=== ${label} ===\n`);
55
+ console.log(result.report);
56
+ console.error(`\n(${result.turns} turns, budget exceeded: ${result.budgetExceeded})`);
57
+ }
58
+ const MANDATORY_IMAGE_OPTION = [
59
+ '--mandatory-image-check',
60
+ "Fetch and attach every step's screenshot to the validator unconditionally, instead of leaving it to the " +
61
+ 'model to request one via view_screenshot when text evidence isn\'t enough. More tokens, stronger ' +
62
+ 'guarantee — a deployment/customer choice, not a testing-methodology one. Defaults to MANDATORY_IMAGE_CHECK.',
63
+ ];
64
+ const DRY_RUN_OPTION = [
65
+ '--dry-run',
66
+ 'Compute verdicts normally but suppress the actual update_run_results/create_defect calls — logs what would ' +
67
+ 'have been written instead. Recommended default for the first runs against any project, per the plan: an ' +
68
+ "LLM-driven process writing pass/fail with zero human in the loop is not something to trust blind on day one.",
69
+ ];
70
+ const JSON_OPTION = [
71
+ '--json',
72
+ 'Print the final result as a single JSON object on stdout instead of a human-readable table. Progress logs ' +
73
+ 'still go to stderr either way, so stdout stays clean for piping/parsing.',
74
+ ];
75
+ const CI_OPTION = [
76
+ '--ci',
77
+ 'Shorthand for --json. Exit code already reflects the real outcome regardless of this flag — non-zero ' +
78
+ 'whenever a non-dry-run test case is failed, blocked, or never settled by the poll timeout — --ci just ' +
79
+ 'switches the final summary to JSON on top of that.',
80
+ ];
81
+ const program = new Command();
82
+ program
83
+ .name('appliqation-autotest')
84
+ .description('Standalone autonomous testing agent that executes Appliqation MCP workflows.');
85
+ program
86
+ .command('judge')
87
+ .description('Judge one test case (--test-case-uuid), a whole scenario (--scenario-id, no --test-case-uuid), or a whole ' +
88
+ 'test set (--test-set-id — the common CI shape: regression/sanity/smoke), as genuinely separate ' +
89
+ 'executor/validator invocations against appq:autotest-executor / -validator — no shared context between ' +
90
+ "them, the validator never sees the executor's own conversation, only what it explicitly submitted as " +
91
+ 'evidence. In whole-scenario and test-set mode, a coverage policy decides per TC whether the agentic pair ' +
92
+ 'runs at all, alongside whatever the deterministic canonical-script pipeline already does automatically; ' +
93
+ 'the report then covers every TC either way. A test set can span multiple scenarios, so that mode resolves ' +
94
+ 'one run per distinct scenario represented rather than one overall. project_id and url are always derived, never ' +
95
+ 'accepted as separate inputs — a caller-supplied value diverging from the real one would either be ' +
96
+ 'silently wrong (url, no server-side check) or rejected late (project_id, appq validates it against the ' +
97
+ "scenario) — deriving instead of asking avoids both failure modes, the same reason MCP tools themselves " +
98
+ 'prefer deducing over trusting a second, possibly-inconsistent input.')
99
+ .option('--test-case-uuid <uuid>', 'test case UUID to judge. Omit to judge a whole scenario instead (then --scenario-id is required). When ' +
100
+ 'given, scenario_id is always derived from it (the UUID is "{scenario_id}-{uuid4}") — --scenario-id is ' +
101
+ 'not accepted alongside it.')
102
+ .option('--scenario-id <id>', 'scenario ID — required in whole-scenario mode (no --test-case-uuid given)')
103
+ .option('--test-set-id <id>', 'judge every test case in this test set instead of one TC or one scenario — the common CI case (regression/' +
104
+ 'sanity/smoke suites). A test set can span multiple scenarios; each distinct scenario gets its own run, ' +
105
+ 'created/reused independently, since appq\'s create_run is inherently scenario-scoped. Mutually exclusive ' +
106
+ 'with --test-case-uuid/--scenario-id; --run-id is not supported here (no single run to reuse).')
107
+ .requiredOption('--environment <name>', 'environment name — its URL (from get_project_settings) is what the browser navigates to; appq will list ' +
108
+ "the available names in its error if the one given doesn't match.")
109
+ .option('--run-id <id>', 'reuse an existing run instead of creating one')
110
+ .option('--role <name>', 'authenticate the executor as this role before navigating, using the Playwright storageState at the path ' +
111
+ '@appliqation/automation-sdk\'s setupAuth({project_id, role}) resolves (~/.appq-auth/ by default). Omit for ' +
112
+ 'ungated projects — no auth handling happens at all in that case, same as before this option existed. If ' +
113
+ 'given and no session exists yet, run `npx appq-auth-setup --project-id <id> --role <name>` first — this ' +
114
+ 'client only ever reads that file, it never performs login or handles credentials itself.')
115
+ .option('--coverage <policy>', 'always | on-script-absence | sampled:N | external — only meaningful in whole-scenario mode; see the plan ' +
116
+ 'doc\'s "coverage decision" for why this is never hardcoded. Defaults to on-script-absence.', 'on-script-absence')
117
+ .option('--test-type <ui|api>', 'force ui (browser) or api (http_request) execution for every TC this invocation touches — same override ' +
118
+ 'shape as --role. Omit and each TC\'s own tag decides instead (isApiTest() — an "API" tag means api, ' +
119
+ 'anything else means ui), which is the normal path in scenario/test-set mode where TCs can legitimately ' +
120
+ 'mix both kinds.')
121
+ .option('--poll-timeout-ms <ms>', 'whole-scenario mode: how long to wait for the deterministic path to settle before reporting. Defaults to POLL_TIMEOUT_MS.')
122
+ .option(...MANDATORY_IMAGE_OPTION)
123
+ .option(...DRY_RUN_OPTION)
124
+ .option(...JSON_OPTION)
125
+ .option(...CI_OPTION)
126
+ .action(async (opts) => {
127
+ const json = (opts.json ?? false) || (opts.ci ?? false);
128
+ const executorAdapter = buildAdapter('executor');
129
+ const validatorAdapter = buildAdapter('validator');
130
+ const mandatoryImageCheck = opts.mandatoryImageCheck ?? config.mandatoryImageCheck;
131
+ const dryRun = opts.dryRun ?? false;
132
+ // Audit scaffolding — one record per invocation regardless of which
133
+ // mode below actually runs (single-TC/whole-scenario/test-set all
134
+ // converge on the same RunSummary shape). `summary` stays undefined
135
+ // for the two "no test cases found" early-return paths — the finally
136
+ // still fires and records that outcome, just with an empty result set.
137
+ const startedAt = Date.now();
138
+ const usage = createUsageAccumulator();
139
+ let summary;
140
+ try {
141
+ if (opts.testType && opts.testType !== 'ui' && opts.testType !== 'api') {
142
+ throw new Error(`--test-type must be "ui" or "api", got "${opts.testType}"`);
143
+ }
144
+ const explicitTestType = opts.testType;
145
+ if (opts.testSetId) {
146
+ // Test-set mode: a test set can span multiple scenarios (appq's own
147
+ // get_test_set describes it as "a collection of test cases from
148
+ // different scenarios"), but create_run/update_run_results is
149
+ // inherently scenario-scoped — so this groups TCs by their own
150
+ // UUID-derived scenario_id and resolves one run + one
151
+ // get_automation_readiness check per distinct scenario, not one
152
+ // overall. --run-id reuse is deliberately not supported here (see
153
+ // the option's own help text) — there's no single run to reuse.
154
+ const testSetId = Number(opts.testSetId);
155
+ const { projectId, tcs } = await fetchTestSetInfo(client, testSetId);
156
+ const url = await resolveUrl(client, opts.environment, projectId);
157
+ const knownRoles = knownRolesForProject(projectId);
158
+ const explicitStorageState = opts.role ? resolveStorageState(projectId, opts.role) : undefined;
159
+ if (opts.role)
160
+ console.error(`[setup] authenticated as role "${opts.role}"`);
161
+ const policy = parseCoveragePolicy(opts.coverage);
162
+ const pollTimeoutMs = opts.pollTimeoutMs ? Number(opts.pollTimeoutMs) : config.pollTimeoutMs;
163
+ if (tcs.length === 0) {
164
+ console.log('No test cases found in this test set.');
165
+ return;
166
+ }
167
+ const byScenario = new Map();
168
+ for (const tc of tcs) {
169
+ const scenarioIdForTc = scenarioIdFromTcUuid(tc.testCaseUuid);
170
+ if (!byScenario.has(scenarioIdForTc))
171
+ byScenario.set(scenarioIdForTc, []);
172
+ byScenario.get(scenarioIdForTc).push(tc);
173
+ }
174
+ console.error(`[setup] test set: ${tcs.length} test cases across ${byScenario.size} scenario(s), coverage: ${opts.coverage}`);
175
+ const runIdByScenario = new Map();
176
+ const canonicalByUuid = new Map();
177
+ for (const scenarioIdForGroup of byScenario.keys()) {
178
+ const readinessResult = await client.callTool('get_automation_readiness', {
179
+ scenario_id: scenarioIdForGroup,
180
+ project_id: projectId,
181
+ });
182
+ if (readinessResult.ok) {
183
+ const readiness = JSON.parse(readinessResult.text).readiness;
184
+ for (const r of readiness)
185
+ canonicalByUuid.set(r.test_case_uuid, r.has_canonical_script);
186
+ }
187
+ else {
188
+ console.error(`[setup] get_automation_readiness failed for scenario ${scenarioIdForGroup}: ${readinessResult.text}`);
189
+ }
190
+ const runId = await resolveRun(client, {
191
+ scenarioId: String(scenarioIdForGroup),
192
+ projectId: String(projectId),
193
+ environment: opts.environment,
194
+ });
195
+ runIdByScenario.set(scenarioIdForGroup, runId);
196
+ }
197
+ console.error(`[setup] image check: ${mandatoryImageCheck ? 'mandatory' : 'on-demand'}, dry-run: ${dryRun}`);
198
+ const covered = [];
199
+ const skipped = [];
200
+ for (const scenarioTcs of byScenario.values()) {
201
+ scenarioTcs.forEach((tc, i) => {
202
+ const hasCanonical = canonicalByUuid.get(tc.testCaseUuid) ?? false;
203
+ const runAgentic = shouldRunAgenticCoverage(policy, { tcIndex: i, hasCanonicalScript: hasCanonical });
204
+ (runAgentic ? covered : skipped).push(tc);
205
+ });
206
+ }
207
+ console.error(`[setup] ${tcs.length} test cases: ${covered.length} get agentic coverage, ${skipped.length} deterministic-only`);
208
+ const inferredStorageStateCache = new Map();
209
+ for (const tc of covered) {
210
+ const scenarioIdForTc = scenarioIdFromTcUuid(tc.testCaseUuid);
211
+ const runId = runIdByScenario.get(scenarioIdForTc);
212
+ console.error(`\n--- judging ${tc.testCaseUuid} (scenario ${scenarioIdForTc}) ---`);
213
+ try {
214
+ const testType = explicitTestType ?? (isApiTest(tc) ? 'api' : 'ui');
215
+ let storageState = explicitStorageState;
216
+ let role = opts.role;
217
+ if (!opts.role) {
218
+ const inferredRole = inferRole(tc, knownRoles);
219
+ if (inferredRole) {
220
+ role = inferredRole;
221
+ if (!inferredStorageStateCache.has(inferredRole)) {
222
+ inferredStorageStateCache.set(inferredRole, resolveStorageState(projectId, inferredRole));
223
+ }
224
+ storageState = inferredStorageStateCache.get(inferredRole);
225
+ console.error(`[${tc.testCaseUuid}] authenticated as role "${inferredRole}" (inferred)`);
226
+ }
227
+ }
228
+ const apiAuthHeader = testType === 'api' && role ? resolveApiAuth(projectId, role) : undefined;
229
+ const { validatorResult } = await judgeTc({
230
+ client,
231
+ runId,
232
+ testCaseUuid: tc.testCaseUuid,
233
+ url,
234
+ testType,
235
+ storageState,
236
+ apiAuthHeader,
237
+ executorAdapter,
238
+ validatorAdapter,
239
+ budget: config.budget,
240
+ mandatoryImageCheck,
241
+ dryRun,
242
+ ringBufferCap: config.evidenceRingBufferCap,
243
+ onEvent: (stage, e) => logEvent(`[${tc.testCaseUuid}:${stage}] `, usage.onUsage)(e),
244
+ });
245
+ console.error(`[${tc.testCaseUuid}] validator finished (${validatorResult.turns} turns)`);
246
+ }
247
+ catch (err) {
248
+ console.error(`[${tc.testCaseUuid}] judge failed: ${err.message}`);
249
+ }
250
+ }
251
+ console.error(`\n[report] polling get_test_results for ${byScenario.size} run(s) (up to ${pollTimeoutMs}ms each)...`);
252
+ const resultsByUuid = new Map();
253
+ if (!dryRun) {
254
+ for (const [scenarioIdForGroup, scenarioTcs] of byScenario) {
255
+ const runId = runIdByScenario.get(scenarioIdForGroup);
256
+ const polled = await pollTestResults(client, {
257
+ runId,
258
+ scenarioId: scenarioIdForGroup,
259
+ wantUuids: new Set(scenarioTcs.map((t) => t.testCaseUuid)),
260
+ timeoutMs: pollTimeoutMs,
261
+ intervalMs: config.pollIntervalMs,
262
+ });
263
+ for (const [uuid, result] of polled)
264
+ resultsByUuid.set(uuid, result);
265
+ }
266
+ }
267
+ const outcomes = tcs.map((tc) => {
268
+ const scenarioIdForTc = scenarioIdFromTcUuid(tc.testCaseUuid);
269
+ const hasCanonical = canonicalByUuid.get(tc.testCaseUuid) ?? false;
270
+ const isCovered = covered.some((c) => c.testCaseUuid === tc.testCaseUuid);
271
+ const path = isCovered ? (hasCanonical ? 'canonical script + agentic' : 'agentic') : 'canonical script';
272
+ const result = resultsByUuid.get(tc.testCaseUuid);
273
+ const status = dryRun ? 'dry-run' : (result?.status ?? 'pending');
274
+ return {
275
+ testCaseUuid: tc.testCaseUuid,
276
+ path,
277
+ status,
278
+ errorMessage: result?.errorMessage,
279
+ runId: runIdByScenario.get(scenarioIdForTc),
280
+ scenarioId: scenarioIdForTc,
281
+ };
282
+ });
283
+ summary = { testSetId, dryRun, results: outcomes };
284
+ if (json)
285
+ printJsonSummary(summary);
286
+ else
287
+ printHumanSummary(summary);
288
+ const pending = outcomes.filter((o) => o.status === 'pending').length;
289
+ if (!dryRun && pending > 0 && !json) {
290
+ console.error(`\n${pending} test case(s) hadn't settled by the poll timeout — check the runs directly for the final state.`);
291
+ }
292
+ process.exitCode = exitCodeFor(summary);
293
+ return;
294
+ }
295
+ const scenarioId = resolveScenarioId(opts);
296
+ const { projectId, tcs } = await fetchScenarioInfo(client, scenarioId);
297
+ const url = await resolveUrl(client, opts.environment, projectId);
298
+ // --role is an explicit override, resolved once and used uniformly —
299
+ // unchanged from before. Without it, per-TC role inference kicks in
300
+ // automatically wherever a TC's tag/name gives a confident signal
301
+ // (see roleInference.ts) — free to compute, just an env var scan, so
302
+ // always run regardless of whether it ends up mattering.
303
+ const knownRoles = knownRolesForProject(projectId);
304
+ const explicitStorageState = opts.role ? resolveStorageState(projectId, opts.role) : undefined;
305
+ if (opts.role)
306
+ console.error(`[setup] authenticated as role "${opts.role}"`);
307
+ const runId = await resolveRun(client, {
308
+ runId: opts.runId,
309
+ scenarioId: String(scenarioId),
310
+ projectId: String(projectId),
311
+ environment: opts.environment,
312
+ });
313
+ console.error(`[setup] image check: ${mandatoryImageCheck ? 'mandatory' : 'on-demand'}, dry-run: ${dryRun}`);
314
+ if (opts.testCaseUuid) {
315
+ // Single-TC mode: unconditional executor/validator pair, no coverage decision.
316
+ const testCaseUuid = opts.testCaseUuid;
317
+ const tcInfo = tcs.find((t) => t.testCaseUuid === testCaseUuid);
318
+ const testType = explicitTestType ?? (tcInfo && isApiTest(tcInfo) ? 'api' : 'ui');
319
+ let storageState = explicitStorageState;
320
+ let role = opts.role;
321
+ if (!opts.role) {
322
+ const inferredRole = tcInfo ? inferRole(tcInfo, knownRoles) : null;
323
+ if (inferredRole) {
324
+ role = inferredRole;
325
+ storageState = resolveStorageState(projectId, inferredRole);
326
+ console.error(`[setup] authenticated as role "${inferredRole}" (inferred)`);
327
+ }
328
+ }
329
+ const apiAuthHeader = testType === 'api' && role ? resolveApiAuth(projectId, role) : undefined;
330
+ const { executorResult, validatorResult } = await judgeTc({
331
+ client,
332
+ runId,
333
+ testCaseUuid,
334
+ url,
335
+ testType,
336
+ storageState,
337
+ apiAuthHeader,
338
+ executorAdapter,
339
+ validatorAdapter,
340
+ budget: config.budget,
341
+ mandatoryImageCheck,
342
+ dryRun,
343
+ ringBufferCap: config.evidenceRingBufferCap,
344
+ onEvent: (stage, e) => logEvent(`[${stage}] `, usage.onUsage)(e),
345
+ });
346
+ if (!json) {
347
+ printResult('Executor report', executorResult);
348
+ printResult('Validator report', validatorResult);
349
+ }
350
+ // The validator writes its own verdict via update_run_results as its
351
+ // last phase — poll appq's own run matrix for the authoritative
352
+ // status rather than trying to parse it back out of the report prose.
353
+ let status = 'dry-run';
354
+ let errorMessage;
355
+ if (!dryRun) {
356
+ const polled = await pollTestResults(client, {
357
+ runId,
358
+ scenarioId,
359
+ wantUuids: new Set([testCaseUuid]),
360
+ timeoutMs: config.pollTimeoutMs,
361
+ intervalMs: config.pollIntervalMs,
362
+ });
363
+ const tc = polled.get(testCaseUuid);
364
+ status = tc?.status ?? 'pending';
365
+ errorMessage = tc?.errorMessage;
366
+ }
367
+ const outcome = { testCaseUuid, path: 'agentic', status, errorMessage };
368
+ summary = { runId, scenarioId, dryRun, results: [outcome] };
369
+ if (json)
370
+ printJsonSummary(summary);
371
+ else
372
+ printHumanSummary(summary);
373
+ process.exitCode = exitCodeFor(summary);
374
+ return;
375
+ }
376
+ // Whole-scenario mode.
377
+ const policy = parseCoveragePolicy(opts.coverage);
378
+ const pollTimeoutMs = opts.pollTimeoutMs ? Number(opts.pollTimeoutMs) : config.pollTimeoutMs;
379
+ console.error(`[setup] coverage: ${opts.coverage}`);
380
+ const readinessResult = await client.callTool('get_automation_readiness', { scenario_id: scenarioId, project_id: projectId });
381
+ if (!readinessResult.ok)
382
+ throw new Error(`get_automation_readiness failed: ${readinessResult.text}`);
383
+ const readiness = JSON.parse(readinessResult.text).readiness;
384
+ if (readiness.length === 0) {
385
+ console.log('No test cases found in this scenario.');
386
+ return;
387
+ }
388
+ const covered = [];
389
+ const skipped = [];
390
+ for (let i = 0; i < readiness.length; i++) {
391
+ const tc = readiness[i];
392
+ const runAgentic = shouldRunAgenticCoverage(policy, { tcIndex: i, hasCanonicalScript: tc.has_canonical_script });
393
+ (runAgentic ? covered : skipped).push(tc.test_case_uuid);
394
+ }
395
+ console.error(`[setup] ${readiness.length} test cases: ${covered.length} get agentic coverage, ${skipped.length} deterministic-only`);
396
+ // Agentic coverage, one TC at a time — sequential, not parallel: each
397
+ // spins up its own browser, and there's no reason yet to pay the
398
+ // resource-contention complexity of running several concurrently.
399
+ const inferredStorageStateCache = new Map();
400
+ for (const tcUuid of covered) {
401
+ console.error(`\n--- judging ${tcUuid} ---`);
402
+ try {
403
+ const tcInfo = tcs.find((t) => t.testCaseUuid === tcUuid);
404
+ const testType = explicitTestType ?? (tcInfo && isApiTest(tcInfo) ? 'api' : 'ui');
405
+ let storageState = explicitStorageState;
406
+ let role = opts.role;
407
+ if (!opts.role) {
408
+ const inferredRole = tcInfo ? inferRole(tcInfo, knownRoles) : null;
409
+ if (inferredRole) {
410
+ role = inferredRole;
411
+ if (!inferredStorageStateCache.has(inferredRole)) {
412
+ inferredStorageStateCache.set(inferredRole, resolveStorageState(projectId, inferredRole));
413
+ }
414
+ storageState = inferredStorageStateCache.get(inferredRole);
415
+ console.error(`[${tcUuid}] authenticated as role "${inferredRole}" (inferred)`);
416
+ }
417
+ }
418
+ const apiAuthHeader = testType === 'api' && role ? resolveApiAuth(projectId, role) : undefined;
419
+ const { validatorResult } = await judgeTc({
420
+ client,
421
+ runId,
422
+ testCaseUuid: tcUuid,
423
+ url,
424
+ testType,
425
+ storageState,
426
+ apiAuthHeader,
427
+ executorAdapter,
428
+ validatorAdapter,
429
+ budget: config.budget,
430
+ mandatoryImageCheck,
431
+ dryRun,
432
+ ringBufferCap: config.evidenceRingBufferCap,
433
+ onEvent: (stage, e) => logEvent(`[${tcUuid}:${stage}] `, usage.onUsage)(e),
434
+ });
435
+ console.error(`[${tcUuid}] validator finished (${validatorResult.turns} turns)`);
436
+ }
437
+ catch (err) {
438
+ console.error(`[${tcUuid}] judge failed: ${err.message}`);
439
+ }
440
+ }
441
+ // One consolidated read of the run matrix — this is where
442
+ // deterministic-only results AND the agentic pair's own writeback
443
+ // (the validator calls update_run_results itself, as its last phase)
444
+ // both show up, so a single poll pass covers every TC either way.
445
+ console.error(`\n[report] polling get_test_results (up to ${pollTimeoutMs}ms)...`);
446
+ const allUuids = new Set(readiness.map((r) => r.test_case_uuid));
447
+ const results = dryRun
448
+ ? new Map() // nothing was actually written in dry-run mode — nothing to poll for
449
+ : await pollTestResults(client, {
450
+ runId,
451
+ scenarioId,
452
+ wantUuids: allUuids,
453
+ timeoutMs: pollTimeoutMs,
454
+ intervalMs: config.pollIntervalMs,
455
+ });
456
+ const outcomes = readiness.map((tc) => {
457
+ const path = covered.includes(tc.test_case_uuid)
458
+ ? tc.has_canonical_script
459
+ ? 'canonical script + agentic'
460
+ : 'agentic'
461
+ : 'canonical script';
462
+ const result = results.get(tc.test_case_uuid);
463
+ const status = dryRun ? 'dry-run' : (result?.status ?? 'pending');
464
+ return { testCaseUuid: tc.test_case_uuid, path, status, errorMessage: result?.errorMessage };
465
+ });
466
+ summary = { runId, scenarioId, dryRun, results: outcomes };
467
+ if (json)
468
+ printJsonSummary(summary);
469
+ else
470
+ printHumanSummary(summary);
471
+ const pending = outcomes.filter((o) => o.status === 'pending').length;
472
+ if (!dryRun && pending > 0 && !json) {
473
+ console.error(`\n${pending} test case(s) hadn't settled by the poll timeout — check the run directly for the final state.`);
474
+ }
475
+ process.exitCode = exitCodeFor(summary);
476
+ }
477
+ finally {
478
+ // Audit write happens whether the run succeeded, threw, or hit an
479
+ // early "no test cases found" return (summary stays undefined in
480
+ // that case) — see @appliqation/agent-core's audit/sink.ts:
481
+ // safeRecord() (used inside recordJudgeRun) never lets a
482
+ // failed/unreachable audit sink affect this process's real outcome.
483
+ await recordJudgeRun({
484
+ sink: config.auditSink,
485
+ startedAt,
486
+ endedAt: Date.now(),
487
+ executorModel: resolveModel('executor'),
488
+ validatorModel: resolveModel('validator'),
489
+ usage: usage.totals(),
490
+ exitCode: Number(process.exitCode ?? 0),
491
+ summary,
492
+ });
493
+ }
494
+ });
495
+ program.parseAsync(process.argv);
496
+ //# sourceMappingURL=index.js.map