@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 +21 -0
- package/README.md +79 -0
- package/dist/cli/audit.js +24 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/index.js +496 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/output.js +33 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/config/env.js +72 -0
- package/dist/config/env.js.map +1 -0
- package/dist/orchestrator/coveragePolicy.js +59 -0
- package/dist/orchestrator/coveragePolicy.js.map +1 -0
- package/dist/orchestrator/judgeTc.js +121 -0
- package/dist/orchestrator/judgeTc.js.map +1 -0
- package/dist/orchestrator/pollResults.js +37 -0
- package/dist/orchestrator/pollResults.js.map +1 -0
- package/dist/tools/browserLabel.js +19 -0
- package/dist/tools/browserLabel.js.map +1 -0
- package/dist/tools/dryRun.js +23 -0
- package/dist/tools/dryRun.js.map +1 -0
- package/dist/tools/safety.js +61 -0
- package/dist/tools/safety.js.map +1 -0
- package/dist/tools/screenshotViewer.js +112 -0
- package/dist/tools/screenshotViewer.js.map +1 -0
- package/package.json +39 -0
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
|