@aarwitz/tapp 0.15.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/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Deterministic multi-actor scenarios. Each actor owns an isolated browser
|
|
2
|
+
// context while all actors interact with the same deployed system. Scenario
|
|
3
|
+
// orchestration never needs a model or coding agent at replay time.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { FlowLog, flowVariables, loadFlowFile, normalizeFlowStep } from "./flow-runtime.js";
|
|
7
|
+
import { executeWebFlowStep, runWebRequestStep } from "./web-flow.js";
|
|
8
|
+
import { loadPlaywright } from "./web-explorer.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TIMEOUT = 6000;
|
|
11
|
+
|
|
12
|
+
export function loadScenarioFile(scenarioPath) {
|
|
13
|
+
return loadFlowFile(scenarioPath);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function actorStepBody(step) {
|
|
17
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) return step;
|
|
18
|
+
if (step.do && typeof step.do === "object") return step.do;
|
|
19
|
+
const body = { ...step };
|
|
20
|
+
delete body.actor;
|
|
21
|
+
return body;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateScenario(scenario) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
if (!scenario || typeof scenario !== "object" || Array.isArray(scenario)) return ["Scenario must be an object"];
|
|
27
|
+
if (scenario.kind && scenario.kind !== "scenario") errors.push("kind must be 'scenario'");
|
|
28
|
+
if (String(scenario.platform || "web").toLowerCase() !== "web") {
|
|
29
|
+
errors.push("multi-actor replay currently supports platform: web; iOS and Android actor isolation are not yet implemented");
|
|
30
|
+
}
|
|
31
|
+
const actors = scenario.actors && typeof scenario.actors === "object" && !Array.isArray(scenario.actors)
|
|
32
|
+
? Object.keys(scenario.actors) : [];
|
|
33
|
+
if (actors.length < 2) errors.push("actors must define at least two isolated actors");
|
|
34
|
+
if (!Array.isArray(scenario.steps) || scenario.steps.length === 0) errors.push("steps must be a non-empty array");
|
|
35
|
+
for (const [index, step] of (scenario.steps || []).entries()) {
|
|
36
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
|
37
|
+
errors.push(`steps[${index}] must be an object`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!actors.includes(step.actor)) errors.push(`steps[${index}].actor must name a defined actor`);
|
|
41
|
+
const action = normalizeFlowStep(actorStepBody(step)).action;
|
|
42
|
+
if (!action || action === "noop") errors.push(`steps[${index}] must define an action`);
|
|
43
|
+
}
|
|
44
|
+
for (const phase of ["setup", "teardown"]) {
|
|
45
|
+
if (scenario[phase] !== undefined && !Array.isArray(scenario[phase])) errors.push(`${phase} must be an array`);
|
|
46
|
+
for (const [index, step] of (scenario[phase] || []).entries()) {
|
|
47
|
+
if (!step?.request || typeof step.request !== "object") errors.push(`${phase}[${index}] must be a request step`);
|
|
48
|
+
else if (!step.request.path) errors.push(`${phase}[${index}].request.path is required`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function substituteExplicit(value, vars) {
|
|
55
|
+
return String(value ?? "").replace(/\$([A-Z][A-Z0-9_]*)/g, (match, key) => {
|
|
56
|
+
if (Object.hasOwn(vars, key)) return String(vars[key]);
|
|
57
|
+
if (Object.hasOwn(process.env, key)) return String(process.env[key]);
|
|
58
|
+
return match;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveVarMap(raw, base = {}) {
|
|
63
|
+
const out = { ...base };
|
|
64
|
+
for (const [key, value] of Object.entries(raw || {})) out[key] = substituteExplicit(value, out);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runWebScenario({ scenario, url, variables = {}, logPath, screenshotDir, playwright }) {
|
|
69
|
+
const errors = validateScenario(scenario);
|
|
70
|
+
if (errors.length) throw new Error(`Invalid Scenario: ${errors.join("; ")}`);
|
|
71
|
+
const startUrl = url || scenario.url || (/^https?:\/\//i.test(scenario.app || "") ? scenario.app : "");
|
|
72
|
+
if (!startUrl) throw new Error("Web Scenario needs `url:` (or an http(s) `app:` value)");
|
|
73
|
+
if (logPath) fs.rmSync(logPath, { force: true });
|
|
74
|
+
|
|
75
|
+
const setup = scenario.setup || [];
|
|
76
|
+
const teardown = scenario.teardown || [];
|
|
77
|
+
const loggedScenario = { ...scenario, kind: "scenario", steps: [...setup, ...scenario.steps, ...teardown] };
|
|
78
|
+
const log = new FlowLog({ logPath, flow: loggedScenario });
|
|
79
|
+
const sharedVars = resolveVarMap({ ...(scenario.vars || {}), ...variables }, flowVariables(scenario));
|
|
80
|
+
const timeout = Number(scenario.timeoutMs) || DEFAULT_TIMEOUT;
|
|
81
|
+
const pw = playwright || await loadPlaywright();
|
|
82
|
+
const browser = await pw.chromium.launch({ headless: true });
|
|
83
|
+
const sessions = new Map();
|
|
84
|
+
let index = 0;
|
|
85
|
+
let failed = false;
|
|
86
|
+
|
|
87
|
+
const emit = (actor, outcome) => {
|
|
88
|
+
index += 1;
|
|
89
|
+
log.step({ index, actor, ...outcome });
|
|
90
|
+
if (outcome.status === "fail") failed = true;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const requestPhase = async (steps, actor) => {
|
|
94
|
+
for (const step of steps) {
|
|
95
|
+
try {
|
|
96
|
+
emit(actor, await runWebRequestStep({ step, startUrl, vars: sharedVars, timeout }));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
emit(actor, { action: "request", target: String(step?.request?.path || "request"), status: "fail", detail: error.message || String(error) });
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await requestPhase(setup, "setup");
|
|
106
|
+
if (!failed) {
|
|
107
|
+
for (const [actor, config] of Object.entries(scenario.actors)) {
|
|
108
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
109
|
+
const page = await context.newPage();
|
|
110
|
+
page.setDefaultTimeout(timeout);
|
|
111
|
+
await page.goto(config.url || startUrl, { waitUntil: "domcontentloaded" });
|
|
112
|
+
const vars = resolveVarMap(config.vars, sharedVars);
|
|
113
|
+
sessions.set(actor, { context, page, vars });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const step of scenario.steps) {
|
|
117
|
+
const session = sessions.get(step.actor);
|
|
118
|
+
const outcome = await executeWebFlowStep({ page: session.page, step: actorStepBody(step), vars: session.vars, defaultTimeout: timeout });
|
|
119
|
+
emit(step.actor, outcome);
|
|
120
|
+
if (outcome.status === "fail" && screenshotDir) {
|
|
121
|
+
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
122
|
+
await session.page.screenshot({ path: path.join(screenshotDir, `scenario-failure-${index}-${step.actor}.png`), fullPage: true }).catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
if (outcome.status === "fail" && !scenario.continueOnFailure) break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
if (screenshotDir) {
|
|
129
|
+
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
130
|
+
for (const [actor, session] of sessions) {
|
|
131
|
+
await session.page.screenshot({ path: path.join(screenshotDir, `scenario-final-${actor}.png`), fullPage: true }).catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await requestPhase(teardown, "teardown");
|
|
135
|
+
for (const { context } of sessions.values()) await context.close().catch(() => {});
|
|
136
|
+
await browser.close().catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
return log.finish();
|
|
139
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Minimal owned-repository static server used only by managed `tapp init
|
|
2
|
+
// --explore` when no project start script exists. Paths are resolved beneath
|
|
3
|
+
// the explicit root and no directory listing or mutation is supported.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const root = fs.realpathSync(path.resolve(process.argv[2] || "."));
|
|
9
|
+
const port = Number(process.argv[3]);
|
|
10
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("static server port must be 1..65535");
|
|
11
|
+
|
|
12
|
+
const types = new Map([
|
|
13
|
+
[".css", "text/css; charset=utf-8"], [".gif", "image/gif"], [".html", "text/html; charset=utf-8"],
|
|
14
|
+
[".ico", "image/x-icon"], [".jpeg", "image/jpeg"], [".jpg", "image/jpeg"], [".js", "text/javascript; charset=utf-8"],
|
|
15
|
+
[".json", "application/json; charset=utf-8"], [".mjs", "text/javascript; charset=utf-8"], [".png", "image/png"],
|
|
16
|
+
[".svg", "image/svg+xml"], [".txt", "text/plain; charset=utf-8"], [".webp", "image/webp"], [".woff2", "font/woff2"],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function ownedPath(url) {
|
|
20
|
+
let pathname;
|
|
21
|
+
try { pathname = decodeURIComponent(new URL(url || "/", "http://127.0.0.1").pathname); }
|
|
22
|
+
catch { return null; }
|
|
23
|
+
const relative = pathname.replace(/^\/+/, "");
|
|
24
|
+
let candidate = path.resolve(root, relative || "index.html");
|
|
25
|
+
if (candidate !== root && !candidate.startsWith(root + path.sep)) return null;
|
|
26
|
+
try {
|
|
27
|
+
if (fs.statSync(candidate).isDirectory()) candidate = path.join(candidate, "index.html");
|
|
28
|
+
} catch {}
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const server = http.createServer((request, response) => {
|
|
33
|
+
const candidate = ownedPath(request.url);
|
|
34
|
+
if (!candidate || !fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
|
|
35
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
36
|
+
response.end("Not found");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
response.writeHead(200, { "content-type": types.get(path.extname(candidate).toLowerCase()) || "application/octet-stream", "cache-control": "no-store" });
|
|
40
|
+
fs.createReadStream(candidate).pipe(response);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
server.listen(port, "127.0.0.1");
|
|
44
|
+
process.on("SIGTERM", () => server.close(() => process.exit(0)));
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Repository-native reusable tasks. A task centralizes semantic navigation and
|
|
2
|
+
// assertions once, then deterministically compiles into the existing shared
|
|
3
|
+
// Flow execution contract. Replay stays keyless and every expanded step keeps
|
|
4
|
+
// task provenance for evidence/review.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { semanticUiKey } from "./ui-map.js";
|
|
10
|
+
|
|
11
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
12
|
+
|
|
13
|
+
function rawSpec(specPath) {
|
|
14
|
+
const helper = path.join(packageRoot, "scripts", "flow_lib.py");
|
|
15
|
+
const parsed = spawnSync("python3", [helper, "raw-json", specPath], { encoding: "utf8" });
|
|
16
|
+
if (parsed.status !== 0) throw new Error((parsed.stderr || parsed.stdout || `Could not parse ${specPath}`).trim());
|
|
17
|
+
return JSON.parse(parsed.stdout);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function taskAction(step) {
|
|
21
|
+
if (!step || typeof step !== "object" || Array.isArray(step)) return "";
|
|
22
|
+
if (typeof step.action === "string") return step.action.toLowerCase();
|
|
23
|
+
return String(Object.keys(step)[0] || "").toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function implementationSteps(task, platform) {
|
|
27
|
+
if (Array.isArray(task.steps)) return { name: "shared", steps: task.steps };
|
|
28
|
+
const implementations = task.implementations || {};
|
|
29
|
+
const selected = implementations[platform] || implementations.shared || implementations.default;
|
|
30
|
+
if (Array.isArray(selected)) return { name: implementations[platform] ? platform : implementations.shared ? "shared" : "default", steps: selected };
|
|
31
|
+
if (Array.isArray(selected?.steps)) return { name: implementations[platform] ? platform : implementations.shared ? "shared" : "default", steps: selected.steps };
|
|
32
|
+
const available = Object.keys(implementations);
|
|
33
|
+
if (!platform && available.length === 1) {
|
|
34
|
+
const only = implementations[available[0]];
|
|
35
|
+
return { name: available[0], steps: Array.isArray(only) ? only : only?.steps };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateTaskDefinition(task) {
|
|
41
|
+
const errors = [];
|
|
42
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) return ["Task must be an object"];
|
|
43
|
+
if (task.kind !== "task") errors.push("kind must be 'task'");
|
|
44
|
+
if (task.version !== 1) errors.push("version must be 1");
|
|
45
|
+
if (!/^[a-z][A-Za-z0-9]*$/.test(String(task.name || ""))) errors.push("name must be lower camelCase");
|
|
46
|
+
if (task.inputs !== undefined && (!task.inputs || typeof task.inputs !== "object" || Array.isArray(task.inputs))) errors.push("inputs must be an object");
|
|
47
|
+
if (task.outputs !== undefined && (!task.outputs || typeof task.outputs !== "object" || Array.isArray(task.outputs))) errors.push("outputs must be an object");
|
|
48
|
+
if (!Array.isArray(task.steps) && (!task.implementations || typeof task.implementations !== "object" || Array.isArray(task.implementations))) {
|
|
49
|
+
errors.push("steps or implementations must define deterministic steps");
|
|
50
|
+
}
|
|
51
|
+
const variants = Array.isArray(task.steps) ? [task.steps] : Object.values(task.implementations || {}).map((value) => Array.isArray(value) ? value : value?.steps);
|
|
52
|
+
for (const [variantIndex, steps] of variants.entries()) {
|
|
53
|
+
if (!Array.isArray(steps) || steps.length === 0) { errors.push(`implementation ${variantIndex + 1} must have steps`); continue; }
|
|
54
|
+
for (const [stepIndex, step] of steps.entries()) {
|
|
55
|
+
const action = taskAction(step);
|
|
56
|
+
if (!action) errors.push(`implementation ${variantIndex + 1} step ${stepIndex + 1} has no action`);
|
|
57
|
+
if (action === "wait") errors.push(`implementation ${variantIndex + 1} step ${stepIndex + 1} uses a fixed wait; use wait_for`);
|
|
58
|
+
if (action === "assert_ai") errors.push(`implementation ${variantIndex + 1} step ${stepIndex + 1} uses assert_ai; tasks must replay deterministically`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const phase of ["preconditions", "postconditions"]) {
|
|
62
|
+
if (task[phase] !== undefined && !Array.isArray(task[phase])) errors.push(`${phase} must be an array`);
|
|
63
|
+
}
|
|
64
|
+
if (task.coverage !== undefined && (!task.coverage || typeof task.coverage !== "object" || Array.isArray(task.coverage))) {
|
|
65
|
+
errors.push("coverage must be an object");
|
|
66
|
+
} else if (task.coverage?.sourceSymbols !== undefined) {
|
|
67
|
+
if (!Array.isArray(task.coverage.sourceSymbols)) errors.push("coverage.sourceSymbols must be an array");
|
|
68
|
+
else for (const [index, owner] of task.coverage.sourceSymbols.entries()) {
|
|
69
|
+
if (!owner || typeof owner !== "object" || Array.isArray(owner) || typeof owner.path !== "string" || !owner.path.trim()) {
|
|
70
|
+
errors.push(`coverage.sourceSymbols[${index}] must define a path`);
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(owner?.symbols) || !owner.symbols.length || owner.symbols.some((symbol) => typeof symbol !== "string" || !/^[A-Za-z_$][\w$]*$/.test(symbol))) {
|
|
73
|
+
errors.push(`coverage.sourceSymbols[${index}].symbols must contain named code symbols`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return errors;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadTaskFile(taskPath) {
|
|
81
|
+
const task = rawSpec(taskPath);
|
|
82
|
+
const errors = validateTaskDefinition(task);
|
|
83
|
+
if (errors.length) throw new Error(`Invalid Task ${taskPath}: ${errors.join("; ")}`);
|
|
84
|
+
return { ...task, __path: path.resolve(taskPath) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findAutotapDir(sourcePath, explicitProjectDir = "") {
|
|
88
|
+
if (explicitProjectDir) return path.join(path.resolve(explicitProjectDir), ".autotap");
|
|
89
|
+
let current = path.dirname(path.resolve(sourcePath));
|
|
90
|
+
while (current !== path.dirname(current)) {
|
|
91
|
+
if (path.basename(current) === ".autotap") return current;
|
|
92
|
+
const candidate = path.join(current, ".autotap");
|
|
93
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
94
|
+
current = path.dirname(current);
|
|
95
|
+
}
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function loadTaskRegistry({ sourcePath, projectDir = "", taskFiles = [] }) {
|
|
100
|
+
const autotapDir = findAutotapDir(sourcePath, projectDir);
|
|
101
|
+
const taskDir = autotapDir ? path.join(autotapDir, "tasks") : "";
|
|
102
|
+
const reviewed = taskDir && fs.existsSync(taskDir)
|
|
103
|
+
? fs.readdirSync(taskDir).filter((name) => /\.ya?ml$|\.json$/i.test(name)).map((name) => path.join(taskDir, name))
|
|
104
|
+
: [];
|
|
105
|
+
// Draft contracts generated under `.autotap/proposals/contracts` may compile
|
|
106
|
+
// against sibling untrusted Task drafts. Ordinary committed contracts never
|
|
107
|
+
// see this directory, so a proposal cannot silently enter the release gate.
|
|
108
|
+
const proposalSource = String(path.resolve(sourcePath || "")).includes(`${path.sep}.autotap${path.sep}proposals${path.sep}`);
|
|
109
|
+
const proposalDir = proposalSource && autotapDir ? path.join(autotapDir, "proposals", "tasks") : "";
|
|
110
|
+
const proposed = proposalDir && fs.existsSync(proposalDir)
|
|
111
|
+
? fs.readdirSync(proposalDir).filter((name) => /\.ya?ml$|\.json$/i.test(name)).map((name) => path.join(proposalDir, name))
|
|
112
|
+
: [];
|
|
113
|
+
const registry = new Map();
|
|
114
|
+
for (const taskPath of [...reviewed, ...proposed, ...taskFiles.map((item) => path.resolve(item))]) {
|
|
115
|
+
const task = loadTaskFile(taskPath);
|
|
116
|
+
if (registry.has(task.name)) throw new Error(`Duplicate Task name '${task.name}'`);
|
|
117
|
+
registry.set(task.name, task);
|
|
118
|
+
}
|
|
119
|
+
return registry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function conditionStep(condition) {
|
|
123
|
+
if (typeof condition === "string") return { assert_screen: condition };
|
|
124
|
+
if (!condition || typeof condition !== "object" || Array.isArray(condition)) throw new Error("Task conditions must be strings or objects");
|
|
125
|
+
if (condition.screen) return { action: "assert_screen", target: condition.screen, timeoutMs: condition.timeoutMs };
|
|
126
|
+
if (condition.exists) return { action: "assert_exists", target: condition.exists, timeoutMs: condition.timeoutMs };
|
|
127
|
+
if (condition.absent) return { action: "assert_absent", target: condition.absent };
|
|
128
|
+
if (condition.text) return { action: "assert_text", ...(typeof condition.text === "object" ? condition.text : { target: condition.text }), timeoutMs: condition.timeoutMs };
|
|
129
|
+
throw new Error(`Unsupported Task condition: ${JSON.stringify(condition)}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function substituteTemplates(value, locals) {
|
|
133
|
+
if (typeof value === "string") return value.replace(/\{\{([A-Za-z][A-Za-z0-9_]*)\}\}/g, (match, key) => {
|
|
134
|
+
if (!Object.hasOwn(locals, key)) throw new Error(`Unknown Task input '{{${key}}}'`);
|
|
135
|
+
return String(locals[key]);
|
|
136
|
+
});
|
|
137
|
+
if (Array.isArray(value)) return value.map((item) => substituteTemplates(item, locals));
|
|
138
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteTemplates(item, locals)]));
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function taskCall(step) {
|
|
143
|
+
if (step?.task && typeof step.task === "string") return { wrapper: null, call: step };
|
|
144
|
+
if (step?.actor && step.do?.task && typeof step.do.task === "string") return { wrapper: step.actor, call: step.do };
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function inputValues(task, call) {
|
|
149
|
+
const supplied = call.with || {};
|
|
150
|
+
const definitions = task.inputs || {};
|
|
151
|
+
const unknown = Object.keys(supplied).filter((key) => !Object.hasOwn(definitions, key));
|
|
152
|
+
if (unknown.length) throw new Error(`Task '${task.name}' received unknown inputs: ${unknown.join(", ")}`);
|
|
153
|
+
const values = {};
|
|
154
|
+
for (const [key, rawDefinition] of Object.entries(definitions)) {
|
|
155
|
+
const definition = rawDefinition && typeof rawDefinition === "object" && !Array.isArray(rawDefinition) ? rawDefinition : { default: rawDefinition };
|
|
156
|
+
if (Object.hasOwn(supplied, key)) values[key] = supplied[key];
|
|
157
|
+
else if (Object.hasOwn(definition, "default")) values[key] = definition.default;
|
|
158
|
+
else if (definition.required !== false) throw new Error(`Task '${task.name}' requires input '${key}'`);
|
|
159
|
+
if (definition.secret && Object.hasOwn(values, key) && !/^\$[A-Z][A-Z0-9_]*$/.test(String(values[key]))) {
|
|
160
|
+
throw new Error(`Task '${task.name}' secret input '${key}' must reference an environment variable such as $TEST_PASSWORD`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return values;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function compileTaskSteps({ steps, registry, platform = "", flowVars = {}, stack = [], plan = [] }) {
|
|
167
|
+
const expanded = [];
|
|
168
|
+
for (const step of steps || []) {
|
|
169
|
+
const invocation = taskCall(step);
|
|
170
|
+
if (!invocation) { expanded.push(step); continue; }
|
|
171
|
+
const task = registry.get(invocation.call.task);
|
|
172
|
+
if (!task) throw new Error(`Task '${invocation.call.task}' was not found in .autotap/tasks`);
|
|
173
|
+
if (stack.includes(task.name)) throw new Error(`Task cycle detected: ${[...stack, task.name].join(" -> ")}`);
|
|
174
|
+
const implementation = implementationSteps(task, platform);
|
|
175
|
+
if (!implementation?.steps) throw new Error(`Task '${task.name}' has no '${platform || "shared"}' implementation`);
|
|
176
|
+
const locals = inputValues(task, invocation.call);
|
|
177
|
+
const rawSteps = [
|
|
178
|
+
...(task.preconditions || []).map(conditionStep),
|
|
179
|
+
...implementation.steps,
|
|
180
|
+
...(task.postconditions || []).map(conditionStep),
|
|
181
|
+
].map((item) => substituteTemplates(item, locals));
|
|
182
|
+
const nested = compileTaskSteps({ steps: rawSteps, registry, platform, flowVars, stack: [...stack, task.name], plan });
|
|
183
|
+
const annotated = nested.steps.map((item, index) => ({
|
|
184
|
+
...item,
|
|
185
|
+
__tappTask: item.__tappTask
|
|
186
|
+
? { ...item.__tappTask, parents: [task.name, ...(item.__tappTask.parents || [])] }
|
|
187
|
+
: { name: task.name, version: task.version, implementation: implementation.name, step: index + 1 },
|
|
188
|
+
}));
|
|
189
|
+
expanded.push(...(invocation.wrapper ? annotated.map((item) => ({ actor: invocation.wrapper, do: item })) : annotated));
|
|
190
|
+
|
|
191
|
+
const inputEvidence = Object.fromEntries(Object.entries(locals).map(([key, value]) => {
|
|
192
|
+
const definition = task.inputs?.[key];
|
|
193
|
+
return [key, definition?.secret ? "<secret>" : value];
|
|
194
|
+
}));
|
|
195
|
+
plan.push({ name: task.name, version: task.version, implementation: implementation.name, inputs: inputEvidence, coverage: task.coverage || { nodes: [], edges: [] }, source: task.__path });
|
|
196
|
+
for (const [outputName, definition] of Object.entries(task.outputs || {})) {
|
|
197
|
+
const saveAs = invocation.call.save?.[outputName];
|
|
198
|
+
if (!saveAs) continue;
|
|
199
|
+
const output = definition?.fromInput ? locals[definition.fromInput] : substituteTemplates(definition?.value ?? definition, locals);
|
|
200
|
+
flowVars[saveAs] = output;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { steps: expanded, vars: flowVars, plan };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function compileFlowTasksFromRepository({ flow, sourcePath, platform = "", projectDir = "", taskFiles = [] }) {
|
|
207
|
+
if (!(flow.steps || []).some(taskCall)) return flow;
|
|
208
|
+
const registry = loadTaskRegistry({ sourcePath, projectDir, taskFiles });
|
|
209
|
+
const vars = { ...(flow.vars || {}) };
|
|
210
|
+
const plan = [];
|
|
211
|
+
const compiled = compileTaskSteps({ steps: flow.steps, registry, platform: platform || flow.platform || (flow.kind === "scenario" ? "web" : ""), flowVars: vars, plan });
|
|
212
|
+
return { ...flow, steps: compiled.steps, vars: compiled.vars, taskPlan: plan, sourceSteps: flow.steps };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function referencedNodes(task, map) {
|
|
216
|
+
const references = task.coverage?.nodes || [];
|
|
217
|
+
return references.map((reference) => map.nodes.find((node) =>
|
|
218
|
+
node.id === reference || semanticUiKey(node.semanticKey) === semanticUiKey(reference) || node.name === reference
|
|
219
|
+
)).filter(Boolean);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function validateTaskAgainstUiMap(task, map, platform = "") {
|
|
223
|
+
const errors = validateTaskDefinition(task);
|
|
224
|
+
const warnings = [];
|
|
225
|
+
if (!map || map.schemaVersion !== 1) return { errors: [...errors, "A UI Map v1 is required"], warnings };
|
|
226
|
+
const requestedNodes = task.coverage?.nodes || [];
|
|
227
|
+
const nodes = referencedNodes(task, map);
|
|
228
|
+
if (nodes.length !== requestedNodes.length) errors.push("coverage.nodes contains states not present in the UI Map");
|
|
229
|
+
const edgeIds = new Set(map.edges.map((edge) => edge.id));
|
|
230
|
+
for (const edge of task.coverage?.edges || []) if (!edgeIds.has(edge)) errors.push(`coverage edge '${edge}' is not present in the UI Map`);
|
|
231
|
+
const implementation = implementationSteps(task, platform);
|
|
232
|
+
if (!implementation?.steps) errors.push(`Task has no '${platform || "shared"}' implementation`);
|
|
233
|
+
const controls = (nodes.length ? nodes : map.nodes).flatMap((node) => node.controls || []);
|
|
234
|
+
const controlKeys = new Set(controls.flatMap((control) => [control.semanticKey, semanticUiKey(control.label), ...(control.selectors || []).map((selector) => semanticUiKey(selector.value))]));
|
|
235
|
+
for (const step of implementation?.steps || []) {
|
|
236
|
+
const action = taskAction(step);
|
|
237
|
+
if (!["tap", "type"].includes(action)) continue;
|
|
238
|
+
const body = typeof step.action === "string" ? step : step[action];
|
|
239
|
+
const target = typeof body === "object" ? body.target || body.field : body;
|
|
240
|
+
if (target && !String(target).includes("{{") && !controlKeys.has(semanticUiKey(target))) warnings.push(`'${target}' was not observed on the Task's covered states`);
|
|
241
|
+
}
|
|
242
|
+
if (!(task.coverage?.edges || []).length) warnings.push("Task does not yet cite any observed UI Map edges");
|
|
243
|
+
return { errors, warnings };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function applyTaskCoverage(map, task) {
|
|
247
|
+
const next = structuredClone(map);
|
|
248
|
+
const nodes = referencedNodes(task, next);
|
|
249
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
250
|
+
const edgeIds = new Set(task.coverage?.edges || []);
|
|
251
|
+
next.coverage ||= { tasks: [], contracts: [], uncoveredNodeIds: [], uncoveredEdgeIds: [] };
|
|
252
|
+
next.coverage.tasks = [...new Set([...(next.coverage.tasks || []), task.name])].sort();
|
|
253
|
+
for (const node of next.nodes) {
|
|
254
|
+
if (!nodeIds.has(node.id)) continue;
|
|
255
|
+
node.coveredBy ||= { tasks: [], contracts: [] };
|
|
256
|
+
node.coveredBy.tasks = [...new Set([...(node.coveredBy.tasks || []), task.name])].sort();
|
|
257
|
+
}
|
|
258
|
+
for (const edge of next.edges) {
|
|
259
|
+
if (!edgeIds.has(edge.id)) continue;
|
|
260
|
+
edge.coveredBy ||= { tasks: [], contracts: [] };
|
|
261
|
+
edge.coveredBy.tasks = [...new Set([...(edge.coveredBy.tasks || []), task.name])].sort();
|
|
262
|
+
}
|
|
263
|
+
next.coverage.uncoveredNodeIds = next.nodes.filter((node) => !(node.coveredBy?.tasks || []).length && !(node.coveredBy?.contracts || []).length).map((node) => node.id).sort();
|
|
264
|
+
next.coverage.uncoveredEdgeIds = next.edges.filter((edge) => !(edge.coveredBy?.tasks || []).length && !(edge.coveredBy?.contracts || []).length).map((edge) => edge.id).sort();
|
|
265
|
+
return next;
|
|
266
|
+
}
|