@treeseed/sdk 0.12.44 → 0.12.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/guarantees/index.d.ts +83 -0
- package/dist/guarantees/index.js +688 -86
- package/dist/hosting/graph.js +40 -6
- package/dist/operations/services/git-workflow.d.ts +16 -1
- package/dist/operations/services/git-workflow.js +65 -5
- package/dist/operations/services/github-api.js +0 -19
- package/dist/operations/services/hosted-service-checks.js +17 -1
- package/dist/operations/services/live-hosted-service-checks.d.ts +4 -0
- package/dist/operations/services/live-hosted-service-checks.js +8 -5
- package/dist/operations/services/local-cleanup.js +3 -7
- package/dist/operations/services/package-adapters.d.ts +14 -0
- package/dist/operations/services/package-adapters.js +36 -3
- package/dist/operations/services/package-artifacts.d.ts +37 -0
- package/dist/operations/services/package-artifacts.js +99 -0
- package/dist/operations/services/railway-deploy.js +78 -18
- package/dist/operations/services/railway-source-policy.d.ts +19 -0
- package/dist/operations/services/railway-source-policy.js +66 -0
- package/dist/operations/services/repository-save-orchestrator.js +88 -19
- package/dist/operations/services/workspace-dependency-mode.js +4 -0
- package/dist/platform/desired-state.js +3 -3
- package/dist/reconcile/builtin-adapters.js +10 -2
- package/dist/reconcile/providers/railway-iac.d.ts +2 -0
- package/dist/reconcile/providers/railway-iac.js +31 -3
- package/dist/reconcile/providers/release-private.d.ts +10 -0
- package/dist/reconcile/providers/release-private.js +45 -1
- package/dist/scenes/builtin-plugins.js +36 -5
- package/dist/scenes/device-matrix.js +2 -0
- package/dist/scenes/environment.js +1 -1
- package/dist/scenes/runner.js +28 -16
- package/dist/scenes/schema.js +31 -2
- package/dist/scenes/types.d.ts +25 -2
- package/dist/scenes/visual-audit-fixtures.js +9 -3
- package/dist/workflow/operations.d.ts +27 -92
- package/dist/workflow/operations.js +252 -144
- package/dist/workflow/runs.d.ts +1 -0
- package/dist/workflow/runs.js +57 -0
- package/dist/workflow-support.d.ts +1 -0
- package/dist/workflow-support.js +8 -0
- package/dist/workflow.d.ts +2 -0
- package/package.json +4 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { runRailwayIac } from "railway/iac";
|
|
4
|
+
import { assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "../../operations/services/railway-source-policy.js";
|
|
4
5
|
function js(value) {
|
|
5
6
|
return JSON.stringify(value);
|
|
6
7
|
}
|
|
@@ -97,7 +98,13 @@ function renderPostgresEnv() {
|
|
|
97
98
|
["RAILWAY_DEPLOYMENT_DRAINING_SECONDS", js("60")]
|
|
98
99
|
]);
|
|
99
100
|
}
|
|
101
|
+
function normalizeIacScope(input) {
|
|
102
|
+
if (input.scope === "prod" || input.scope === "staging") return input.scope;
|
|
103
|
+
const environmentName = String(input.environmentName ?? "").trim().toLowerCase();
|
|
104
|
+
return environmentName === "production" || environmentName === "prod" ? "prod" : environmentName === "staging" ? "staging" : "local";
|
|
105
|
+
}
|
|
100
106
|
function renderRailwayIacProject(input) {
|
|
107
|
+
const scope = normalizeIacScope(input);
|
|
101
108
|
const region = input.region?.trim() || "us-east4-eqdc4a";
|
|
102
109
|
const tempParent = resolve(input.tenantRoot, ".treeseed", "tmp");
|
|
103
110
|
mkdirSync(tempParent, { recursive: true });
|
|
@@ -145,6 +152,7 @@ function renderRailwayIacProject(input) {
|
|
|
145
152
|
}
|
|
146
153
|
}
|
|
147
154
|
input.services.forEach((service, index) => {
|
|
155
|
+
assertApiRailwaySourcePolicy(scope, service);
|
|
148
156
|
const serviceVar = id("svc", index);
|
|
149
157
|
const invalidVariables = validateGeneratedVariables(service);
|
|
150
158
|
if (invalidVariables.length > 0) {
|
|
@@ -232,7 +240,15 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
|
|
|
232
240
|
const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
|
|
233
241
|
for (const change of changeSet?.changes ?? []) {
|
|
234
242
|
const name = changeName(change);
|
|
235
|
-
const
|
|
243
|
+
const serviceName = name.replace(/^(service|database)\./u, "");
|
|
244
|
+
const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[serviceName] ?? null;
|
|
245
|
+
const sourceRef = desiredNames.serviceSourceRefs?.[name] ?? desiredNames.serviceSourceRefs?.[serviceName] ?? null;
|
|
246
|
+
const sourceChanged = change.kind === "resource.update" && isRailwaySourceChange(change);
|
|
247
|
+
const imageSourceChange = sourceChanged && isRailwayImageSourceChange(change);
|
|
248
|
+
const gitSourceChange = sourceChanged && isRailwayGitSourceChange(change);
|
|
249
|
+
const desiredGitSource = sourceMode === "git" && typeof sourceRef === "string" && sourceRef.startsWith("github:");
|
|
250
|
+
const desiredImageSource = sourceMode === "image" && typeof sourceRef === "string" && sourceRef.startsWith("image:");
|
|
251
|
+
const apiPolicyService = isApiRailwaySourcePolicyService({ serviceName });
|
|
236
252
|
if (change.kind === "resource.delete") {
|
|
237
253
|
destructiveChanges.push(change.summary);
|
|
238
254
|
blockedReasons.push(`Railway IaC plan would delete resource ${name || change.summary}; hosting reconciliation only updates or creates resources. Use the explicit destroy workflow for deletions.`);
|
|
@@ -240,12 +256,24 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
|
|
|
240
256
|
blockedReasons.push(`Railway IaC plan would delete desired resource ${name}.`);
|
|
241
257
|
}
|
|
242
258
|
}
|
|
243
|
-
if (desiredNames.scope === "staging" &&
|
|
259
|
+
if (desiredNames.scope === "staging" && sourceChanged && apiPolicyService && sourceMode === "git" && !gitSourceChange && !desiredGitSource) {
|
|
260
|
+
blockedReasons.push(`Railway IaC plan would change staging API resource ${name} source without confirming a GitHub source.`);
|
|
261
|
+
}
|
|
262
|
+
if (desiredNames.scope === "staging" && sourceChanged && imageSourceChange && !(apiPolicyService && sourceMode === "git" && (gitSourceChange || desiredGitSource))) {
|
|
244
263
|
blockedReasons.push(`Railway IaC plan would switch staging resource ${name} to an image source.`);
|
|
245
264
|
}
|
|
246
|
-
if (desiredNames.scope === "
|
|
265
|
+
if (desiredNames.scope === "staging" && sourceChanged && (!sourceMode || sourceMode === "image")) {
|
|
266
|
+
blockedReasons.push(`Railway IaC plan would apply an image-backed desired source to staging resource ${name}.`);
|
|
267
|
+
}
|
|
268
|
+
if (desiredNames.scope === "prod" && sourceChanged && apiPolicyService && sourceMode === "image" && !imageSourceChange && !desiredImageSource) {
|
|
269
|
+
blockedReasons.push(`Railway IaC plan would change production API resource ${name} source without confirming an image source.`);
|
|
270
|
+
}
|
|
271
|
+
if (desiredNames.scope === "prod" && sourceChanged && gitSourceChange) {
|
|
247
272
|
blockedReasons.push(`Railway IaC plan would switch production resource ${name} to a Git source.`);
|
|
248
273
|
}
|
|
274
|
+
if (desiredNames.scope === "prod" && sourceChanged && (!sourceMode || sourceMode === "git")) {
|
|
275
|
+
blockedReasons.push(`Railway IaC plan would apply a Git-backed desired source to production resource ${name}.`);
|
|
276
|
+
}
|
|
249
277
|
}
|
|
250
278
|
return {
|
|
251
279
|
ok: blockedReasons.length === 0,
|
|
@@ -11,6 +11,7 @@ export declare function runReleaseVerifyCommand(input: {
|
|
|
11
11
|
status?: undefined;
|
|
12
12
|
signal?: undefined;
|
|
13
13
|
command?: undefined;
|
|
14
|
+
dependencies?: undefined;
|
|
14
15
|
stdout?: undefined;
|
|
15
16
|
stderr?: undefined;
|
|
16
17
|
} | {
|
|
@@ -18,6 +19,15 @@ export declare function runReleaseVerifyCommand(input: {
|
|
|
18
19
|
status: number | null;
|
|
19
20
|
signal: NodeJS.Signals | null;
|
|
20
21
|
command: import("../../index.js").TreeseedPackageCommand;
|
|
22
|
+
dependencies: {
|
|
23
|
+
status: "not-applicable";
|
|
24
|
+
} | {
|
|
25
|
+
status: "ready";
|
|
26
|
+
} | {
|
|
27
|
+
status: "restored";
|
|
28
|
+
} | {
|
|
29
|
+
status: "workspace-linked";
|
|
30
|
+
};
|
|
21
31
|
stdout: string;
|
|
22
32
|
stderr: string;
|
|
23
33
|
skipped?: undefined;
|
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { findTreeseedPackageAdapter } from "../../operations/services/package-adapters.js";
|
|
5
5
|
import { checkedOutTemplateRepositories } from "../../operations/services/managed-repositories.js";
|
|
6
6
|
import { runTreeseedGitText } from "../../operations/services/git-runner.js";
|
|
7
|
+
import { ensureLocalWorkspaceLinks } from "../../operations/services/workspace-dependency-mode.js";
|
|
8
|
+
function ensureReleaseVerifyDependencies(input) {
|
|
9
|
+
if (!existsSync(resolve(input.packageDir, "package.json")) || !existsSync(resolve(input.packageDir, "package-lock.json"))) {
|
|
10
|
+
return { status: "not-applicable" };
|
|
11
|
+
}
|
|
12
|
+
const env = { ...process.env, ...input.env ?? {} };
|
|
13
|
+
const inspection = spawnSync("npm", ["ls", "--depth=0", "--workspaces=false"], {
|
|
14
|
+
cwd: input.packageDir,
|
|
15
|
+
env,
|
|
16
|
+
encoding: "utf8"
|
|
17
|
+
});
|
|
18
|
+
if (inspection.status === 0) return { status: "ready" };
|
|
19
|
+
input.onProgress?.("Package dependencies are incomplete; restoring the standalone lockfile installation.");
|
|
20
|
+
const install = spawnSync("npm", [
|
|
21
|
+
"ci",
|
|
22
|
+
"--ignore-scripts",
|
|
23
|
+
"--workspaces=false",
|
|
24
|
+
"--no-audit",
|
|
25
|
+
"--no-fund"
|
|
26
|
+
], {
|
|
27
|
+
cwd: input.packageDir,
|
|
28
|
+
env,
|
|
29
|
+
encoding: "utf8"
|
|
30
|
+
});
|
|
31
|
+
if (install.status !== 0) {
|
|
32
|
+
throw new Error([
|
|
33
|
+
"Standalone package dependency hydration failed before release verification.",
|
|
34
|
+
install.stderr,
|
|
35
|
+
install.stdout
|
|
36
|
+
].filter(Boolean).join("\n").trim());
|
|
37
|
+
}
|
|
38
|
+
ensureLocalWorkspaceLinks(input.tenantRoot, { env: input.env });
|
|
39
|
+
return { status: "restored" };
|
|
40
|
+
}
|
|
7
41
|
async function runReleaseVerifyCommand(input) {
|
|
8
42
|
const adapter = findTreeseedPackageAdapter(input.tenantRoot, input.packageId);
|
|
9
43
|
if (!adapter) {
|
|
@@ -17,6 +51,15 @@ async function runReleaseVerifyCommand(input) {
|
|
|
17
51
|
reason: `${input.packageId} has no release verify command.`
|
|
18
52
|
};
|
|
19
53
|
}
|
|
54
|
+
const dependencies = adapter.capabilities.localOnly ? (() => {
|
|
55
|
+
ensureLocalWorkspaceLinks(input.tenantRoot, { env: input.env });
|
|
56
|
+
return { status: "workspace-linked" };
|
|
57
|
+
})() : ensureReleaseVerifyDependencies({
|
|
58
|
+
tenantRoot: input.tenantRoot,
|
|
59
|
+
packageDir: adapter.dir,
|
|
60
|
+
env: input.env,
|
|
61
|
+
onProgress: input.onProgress
|
|
62
|
+
});
|
|
20
63
|
const renderedCommand = [command.command, ...command.args].join(" ");
|
|
21
64
|
input.onProgress?.(`Running ${input.packageId} release verification: ${renderedCommand}`);
|
|
22
65
|
const started = Date.now();
|
|
@@ -53,6 +96,7 @@ async function runReleaseVerifyCommand(input) {
|
|
|
53
96
|
status: result.status,
|
|
54
97
|
signal: result.signal,
|
|
55
98
|
command,
|
|
99
|
+
dependencies,
|
|
56
100
|
stdout,
|
|
57
101
|
stderr
|
|
58
102
|
};
|
|
@@ -15,6 +15,19 @@ function mailpitApiUrl(mailpitUrl, pathname) {
|
|
|
15
15
|
function stringValue(value) {
|
|
16
16
|
return typeof value === "string" ? value : "";
|
|
17
17
|
}
|
|
18
|
+
function shortRuntimeHash(value) {
|
|
19
|
+
let hash = 2166136261;
|
|
20
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
21
|
+
hash ^= value.charCodeAt(index);
|
|
22
|
+
hash = Math.imul(hash, 16777619);
|
|
23
|
+
}
|
|
24
|
+
return (hash >>> 0).toString(36).padStart(7, "0").slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
function sceneRuntimeValue(value, context) {
|
|
27
|
+
const runSlug = context.runId.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 48);
|
|
28
|
+
const runShort = shortRuntimeHash(context.runId);
|
|
29
|
+
return value.replace(/\{\{\s*runId\s*\}\}/gu, context.runId).replace(/\{\{\s*runSlug\s*\}\}/gu, runSlug).replace(/\{\{\s*runShort\s*\}\}/gu, runShort);
|
|
30
|
+
}
|
|
18
31
|
function mailpitMessageId(value) {
|
|
19
32
|
if (!value || typeof value !== "object") return "";
|
|
20
33
|
const record = value;
|
|
@@ -57,6 +70,16 @@ function extractConfirmationUrl(body) {
|
|
|
57
70
|
const relative = body.match(/(?:\/auth\/confirm-email\?[^"' <>\n]+|\/team-invites\/[^"' <>\n]+\/accept)/u)?.[0];
|
|
58
71
|
return relative ?? null;
|
|
59
72
|
}
|
|
73
|
+
function resolveMailpitConfirmationUrl(confirmationUrl, context) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = new URL(confirmationUrl);
|
|
76
|
+
if (parsed.pathname === "/auth/confirm-email" || /^\/team-invites\/[^/]+\/accept$/u.test(parsed.pathname)) {
|
|
77
|
+
return context.resolveUrl(`${parsed.pathname}${parsed.search}${parsed.hash}`);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
return confirmationUrl.startsWith("/") ? context.resolveUrl(confirmationUrl) : confirmationUrl;
|
|
82
|
+
}
|
|
60
83
|
async function sleep(ms) {
|
|
61
84
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
62
85
|
}
|
|
@@ -68,7 +91,11 @@ async function navigateScenePage(page, url) {
|
|
|
68
91
|
let lastError = null;
|
|
69
92
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
70
93
|
try {
|
|
71
|
-
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
94
|
+
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
95
|
+
const status = response?.status();
|
|
96
|
+
if (typeof status === "number" && status >= 400) {
|
|
97
|
+
throw sceneErrorDiagnostic("scene.navigation_http_error", `Navigation to ${response?.url() ?? url} returned HTTP ${status}.`, "workflow.action.goto");
|
|
98
|
+
}
|
|
72
99
|
return;
|
|
73
100
|
} catch (error) {
|
|
74
101
|
lastError = error;
|
|
@@ -76,7 +103,7 @@ async function navigateScenePage(page, url) {
|
|
|
76
103
|
await sleep(500 * attempt);
|
|
77
104
|
}
|
|
78
105
|
}
|
|
79
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? `Navigation failed for ${url}`));
|
|
106
|
+
throw lastError && typeof lastError === "object" && "code" in lastError ? lastError : lastError instanceof Error ? lastError : new Error(String(lastError ?? `Navigation failed for ${url}`));
|
|
80
107
|
}
|
|
81
108
|
async function assertionReport(kind, action, selector) {
|
|
82
109
|
const startedAt = /* @__PURE__ */ new Date();
|
|
@@ -132,7 +159,7 @@ function createBuiltInTreeseedScenePlugins() {
|
|
|
132
159
|
if (!("fill" in action)) return { ok: false, diagnostics: [sceneErrorDiagnostic("scene.invalid_action", "Expected fill action.", "workflow.action.fill")] };
|
|
133
160
|
const locator = context.resolveSelector(action.fill);
|
|
134
161
|
await locator.waitFor({ state: "visible", timeout: 1e4 });
|
|
135
|
-
await locator.fill(action.fill.value);
|
|
162
|
+
await locator.fill(sceneRuntimeValue(action.fill.value, context));
|
|
136
163
|
return { ok: true, diagnostics: [] };
|
|
137
164
|
}
|
|
138
165
|
},
|
|
@@ -208,7 +235,11 @@ function createBuiltInTreeseedScenePlugins() {
|
|
|
208
235
|
summary: "Confirm the latest local Mailpit email by navigating the browser to its confirmation link.",
|
|
209
236
|
async run({ action, step, context }) {
|
|
210
237
|
if (!("mailpitConfirmLatest" in action)) return { ok: false, diagnostics: [sceneErrorDiagnostic("scene.invalid_action", "Expected mailpitConfirmLatest action.", `workflow.${step.id}.action.mailpitConfirmLatest`)] };
|
|
211
|
-
const
|
|
238
|
+
const raw = action.mailpitConfirmLatest;
|
|
239
|
+
const mailpitUrl = sceneRuntimeValue(raw.mailpitUrl, context);
|
|
240
|
+
const email = sceneRuntimeValue(raw.email, context);
|
|
241
|
+
const subjectIncludes = raw.subjectIncludes ? sceneRuntimeValue(raw.subjectIncludes, context) : void 0;
|
|
242
|
+
const { displayInboxSeconds, displayMessageSeconds } = raw;
|
|
212
243
|
try {
|
|
213
244
|
const listResponse = await fetch(mailpitApiUrl(mailpitUrl, "/api/v1/messages"));
|
|
214
245
|
if (!listResponse.ok) {
|
|
@@ -233,7 +264,7 @@ function createBuiltInTreeseedScenePlugins() {
|
|
|
233
264
|
if (!confirmationUrl) {
|
|
234
265
|
return { ok: false, diagnostics: [sceneErrorDiagnostic("scene.mailpit_confirm_link_not_found", `No confirmation link was found in Mailpit message ${id}.`, `workflow.${step.id}.action.mailpitConfirmLatest`)] };
|
|
235
266
|
}
|
|
236
|
-
const resolvedUrl = confirmationUrl
|
|
267
|
+
const resolvedUrl = resolveMailpitConfirmationUrl(confirmationUrl, context);
|
|
237
268
|
const mailpitBase = mailpitUrl.endsWith("/") ? mailpitUrl : `${mailpitUrl}/`;
|
|
238
269
|
if (displayInboxSeconds && displayInboxSeconds > 0) {
|
|
239
270
|
const search = new URL("search", mailpitBase);
|
|
@@ -63,6 +63,8 @@ async function runTreeseedSceneDeviceMatrix(input) {
|
|
|
63
63
|
scene: input.scene,
|
|
64
64
|
environment: input.environment,
|
|
65
65
|
device,
|
|
66
|
+
browser: input.browser,
|
|
67
|
+
authRole: input.authRole,
|
|
66
68
|
record: input.record,
|
|
67
69
|
artifactMode: input.artifactMode,
|
|
68
70
|
mode: input.mode,
|
|
@@ -39,7 +39,7 @@ async function prepareTreeseedSceneEnvironment(input) {
|
|
|
39
39
|
baseUrl = healthUrl(existing);
|
|
40
40
|
} else {
|
|
41
41
|
try {
|
|
42
|
-
const result = await startTreeseedManagedDev({ cwd: input.projectRoot, surfaces: "web,api
|
|
42
|
+
const result = await startTreeseedManagedDev({ cwd: input.projectRoot, surfaces: "web,api", webRuntime: "local", env: input.env });
|
|
43
43
|
instances = result.instances;
|
|
44
44
|
const web = result.instances.find((entry) => entry.surface === "web" || entry.id === "web");
|
|
45
45
|
started = Boolean(web && running(web));
|
package/dist/scenes/runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { sceneActionKind, sceneExpectationKinds } from "./schema.js";
|
|
4
4
|
import { planTreeseedScene, validateTreeseedScene } from "./planner.js";
|
|
@@ -126,6 +126,7 @@ function planForInput(input, validation) {
|
|
|
126
126
|
}
|
|
127
127
|
const scene = validation.scene;
|
|
128
128
|
const environment = input.environment ?? scene.target.environment;
|
|
129
|
+
const browser = input.browser ?? scene.target.browser;
|
|
129
130
|
const workflowSteps = scene.workflow.map((step) => ({
|
|
130
131
|
id: step.id,
|
|
131
132
|
title: step.title,
|
|
@@ -145,7 +146,7 @@ function planForInput(input, validation) {
|
|
|
145
146
|
title: scene.title,
|
|
146
147
|
environment,
|
|
147
148
|
baseUrl: scene.target.baseUrl,
|
|
148
|
-
browser
|
|
149
|
+
browser,
|
|
149
150
|
viewport: scene.target.viewport,
|
|
150
151
|
workflowSteps,
|
|
151
152
|
enabledActions: actionIds,
|
|
@@ -174,6 +175,20 @@ function planForInput(input, validation) {
|
|
|
174
175
|
function canContinueAfterFailure(scene, step) {
|
|
175
176
|
return scene.runtime.mode === "demo" || scene.runtime.mode === "training" || step.demoOnly === true || step.continueOnFailure === true || scene.runtime.failure.continueOnFailure === true;
|
|
176
177
|
}
|
|
178
|
+
function sceneWithRunOverrides(scene, input) {
|
|
179
|
+
if (!input.authRole) return scene;
|
|
180
|
+
return {
|
|
181
|
+
...scene,
|
|
182
|
+
setup: {
|
|
183
|
+
...scene.setup,
|
|
184
|
+
auth: {
|
|
185
|
+
...scene.setup.auth ?? {},
|
|
186
|
+
required: input.authRole !== "anonymous",
|
|
187
|
+
role: scene.setup.auth?.role ?? input.authRole
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
177
192
|
async function runTreeseedScene(input) {
|
|
178
193
|
const startedAt = now();
|
|
179
194
|
const validation = validationForInput(input);
|
|
@@ -188,7 +203,8 @@ async function runTreeseedScene(input) {
|
|
|
188
203
|
diagnostics: validation.diagnostics
|
|
189
204
|
});
|
|
190
205
|
}
|
|
191
|
-
const scene = validation.scene;
|
|
206
|
+
const scene = sceneWithRunOverrides(validation.scene, input);
|
|
207
|
+
const browser = input.browser ?? scene.target.browser;
|
|
192
208
|
const deviceResolution = resolveTreeseedSceneDeviceProfile({ scene, device: input.device });
|
|
193
209
|
if (!deviceResolution.profile && deviceResolution.diagnostics.some((entry) => entry.severity === "error")) {
|
|
194
210
|
return reportFromBlock({
|
|
@@ -197,7 +213,7 @@ async function runTreeseedScene(input) {
|
|
|
197
213
|
runId: input.runId ?? null,
|
|
198
214
|
startedAt,
|
|
199
215
|
environment: input.environment ?? scene.target.environment,
|
|
200
|
-
browser
|
|
216
|
+
browser,
|
|
201
217
|
diagnostics: deviceResolution.diagnostics
|
|
202
218
|
});
|
|
203
219
|
}
|
|
@@ -214,7 +230,7 @@ async function runTreeseedScene(input) {
|
|
|
214
230
|
runId: input.runId ?? null,
|
|
215
231
|
startedAt,
|
|
216
232
|
environment: plan.environment,
|
|
217
|
-
browser
|
|
233
|
+
browser,
|
|
218
234
|
device,
|
|
219
235
|
diagnostics: plan.diagnostics
|
|
220
236
|
});
|
|
@@ -263,7 +279,7 @@ async function runTreeseedScene(input) {
|
|
|
263
279
|
runId: paths.runId,
|
|
264
280
|
startedAt,
|
|
265
281
|
environment: plan.environment,
|
|
266
|
-
browser
|
|
282
|
+
browser,
|
|
267
283
|
device,
|
|
268
284
|
diagnostics: [...plan.diagnostics, ...setupDiagnostics],
|
|
269
285
|
artifacts,
|
|
@@ -289,7 +305,7 @@ async function runTreeseedScene(input) {
|
|
|
289
305
|
runId: paths.runId,
|
|
290
306
|
startedAt,
|
|
291
307
|
environment: plan.environment,
|
|
292
|
-
browser
|
|
308
|
+
browser,
|
|
293
309
|
device,
|
|
294
310
|
diagnostics: [...plan.diagnostics, ...setupDiagnostics, ...baseUrl.diagnostics],
|
|
295
311
|
artifacts,
|
|
@@ -338,7 +354,7 @@ async function runTreeseedScene(input) {
|
|
|
338
354
|
let sessionClosed = false;
|
|
339
355
|
try {
|
|
340
356
|
session = await adapter.launch({
|
|
341
|
-
browser
|
|
357
|
+
browser,
|
|
342
358
|
viewport: capture.viewport,
|
|
343
359
|
videoSize: capture.videoSize,
|
|
344
360
|
recordVideoDir: videoDir,
|
|
@@ -380,7 +396,7 @@ async function runTreeseedScene(input) {
|
|
|
380
396
|
timeline.push("network", entry, currentStepId);
|
|
381
397
|
});
|
|
382
398
|
if (tracePath) await session.startTracing?.();
|
|
383
|
-
if (scene.setup.auth?.role && scene.setup.auth.role !== "anonymous") {
|
|
399
|
+
if (scene.setup.auth?.role && scene.setup.auth.role !== "anonymous" && scene.setup.auth.seedOnly !== true) {
|
|
384
400
|
const signInDiagnostics = await signInTreeseedSceneVisualAuditRole({
|
|
385
401
|
page: session.page,
|
|
386
402
|
baseUrl: baseUrl.baseUrl,
|
|
@@ -446,6 +462,7 @@ async function runTreeseedScene(input) {
|
|
|
446
462
|
projectRoot: input.projectRoot,
|
|
447
463
|
scene,
|
|
448
464
|
environment: plan.environment,
|
|
465
|
+
runId: paths.runId,
|
|
449
466
|
session,
|
|
450
467
|
baseUrl: baseUrl.baseUrl,
|
|
451
468
|
timeline,
|
|
@@ -507,15 +524,10 @@ async function runTreeseedScene(input) {
|
|
|
507
524
|
await session.page.screenshot({ path: viewportScreenshotPath, fullPage: false });
|
|
508
525
|
artifacts.viewportScreenshotPaths?.push(viewportScreenshotPath);
|
|
509
526
|
timeline.push("screenshot.viewport", { path: viewportScreenshotPath }, step.id);
|
|
510
|
-
if (screenshotPath && recordingVideo) {
|
|
511
|
-
copyFileSync(viewportScreenshotPath, screenshotPath);
|
|
512
|
-
artifacts.screenshotPaths.push(screenshotPath);
|
|
513
|
-
timeline.push("screenshot", { path: screenshotPath, captureKind: "viewport-copy" }, step.id);
|
|
514
|
-
}
|
|
515
527
|
} catch {
|
|
516
528
|
}
|
|
517
529
|
}
|
|
518
|
-
if (screenshotPath
|
|
530
|
+
if (screenshotPath) {
|
|
519
531
|
try {
|
|
520
532
|
await session.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
521
533
|
artifacts.screenshotPaths.push(screenshotPath);
|
|
@@ -637,7 +649,7 @@ async function runTreeseedScene(input) {
|
|
|
637
649
|
durationMs: duration(startedAt, finishedAt),
|
|
638
650
|
environment: plan.environment,
|
|
639
651
|
baseUrl: baseUrl.baseUrl,
|
|
640
|
-
browser
|
|
652
|
+
browser,
|
|
641
653
|
device,
|
|
642
654
|
capture,
|
|
643
655
|
workflowStatus,
|
package/dist/scenes/schema.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
TREESEED_SCENE_SCHEMA_VERSION
|
|
7
7
|
} from "./types.js";
|
|
8
8
|
const FILESYSTEM_SAFE_SCENE_ID = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
9
|
-
const TOP_LEVEL_FIELDS = /* @__PURE__ */ new Set(["schemaVersion", "id", "title", "description", "audience", "mode", "target", "devices", "setup", "artifacts", "workflow", "chapters", "overlays", "diagrams", "render", "runtime", "training", "visualAudit", "xScenario"]);
|
|
9
|
+
const TOP_LEVEL_FIELDS = /* @__PURE__ */ new Set(["schemaVersion", "id", "title", "description", "audience", "journey", "mode", "target", "devices", "setup", "artifacts", "workflow", "chapters", "overlays", "diagrams", "render", "runtime", "training", "visualAudit", "xScenario"]);
|
|
10
10
|
const FILESYSTEM_SAFE_CHECKPOINT_ID = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
11
11
|
const DIAGRAM_PLACEMENTS = ["overlay", "interstitial", "standalone"];
|
|
12
12
|
const CAPTION_FORMATS = ["vtt", "srt"];
|
|
@@ -108,6 +108,34 @@ function stringArrayField(record, field, path, diagnostics) {
|
|
|
108
108
|
});
|
|
109
109
|
return strings;
|
|
110
110
|
}
|
|
111
|
+
function stateRefArray(record, field, path, diagnostics) {
|
|
112
|
+
const value = arrayField(record, field, path, diagnostics);
|
|
113
|
+
if (!value) return void 0;
|
|
114
|
+
const refs = [];
|
|
115
|
+
value.forEach((entry, index) => {
|
|
116
|
+
const entryPath = `${path}.${field}[${index}]`;
|
|
117
|
+
if (!isRecord(entry)) {
|
|
118
|
+
diagnostics.push(sceneErrorDiagnostic("scene.invalid_state_ref", `Expected ${field} entry to be an object.`, entryPath));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
refs.push({ key: requireString(entry, "key", entryPath, diagnostics), kind: requireString(entry, "kind", entryPath, diagnostics) });
|
|
122
|
+
});
|
|
123
|
+
return refs;
|
|
124
|
+
}
|
|
125
|
+
function parseJourney(record, diagnostics) {
|
|
126
|
+
const journey = objectField(record, "journey", "manifest", diagnostics);
|
|
127
|
+
if (!journey) return void 0;
|
|
128
|
+
const kind = optionalString(journey, "kind");
|
|
129
|
+
if (kind && !["service", "page", "visual-audit"].includes(kind)) diagnostics.push(sceneErrorDiagnostic("scene.invalid_journey_kind", `Unsupported journey kind: ${kind}.`, "journey.kind"));
|
|
130
|
+
return {
|
|
131
|
+
kind: kind === "page" || kind === "visual-audit" ? kind : "service",
|
|
132
|
+
proves: stringArrayField(journey, "proves", "journey", diagnostics),
|
|
133
|
+
minimumSteps: positiveNumberField(journey, "minimumSteps", void 0, "journey", diagnostics),
|
|
134
|
+
requiresInteractiveAction: booleanField(journey, "requiresInteractiveAction", false, "journey", diagnostics),
|
|
135
|
+
producesState: stateRefArray(journey, "producesState", "journey", diagnostics),
|
|
136
|
+
consumesState: stateRefArray(journey, "consumesState", "journey", diagnostics)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
111
139
|
function enumArrayField(record, field, allowed, defaultValue, path, diagnostics) {
|
|
112
140
|
const value = record[field];
|
|
113
141
|
if (value === void 0) return defaultValue;
|
|
@@ -379,7 +407,7 @@ function parseSetup(value, targetEnvironment, diagnostics) {
|
|
|
379
407
|
const dev = objectField(record, "dev", "setup", diagnostics);
|
|
380
408
|
if (dev) setup.dev = { required: booleanField(dev, "required", false, "setup.dev", diagnostics), command: optionalString(dev, "command"), reuseExisting: booleanField(dev, "reuseExisting", true, "setup.dev", diagnostics) };
|
|
381
409
|
const auth = objectField(record, "auth", "setup", diagnostics);
|
|
382
|
-
if (auth) setup.auth = { profile: optionalString(auth, "profile"), required: booleanField(auth, "required", false, "setup.auth", diagnostics), ...optionalString(auth, "role") ? { role: optionalString(auth, "role") } : {} };
|
|
410
|
+
if (auth) setup.auth = { profile: optionalString(auth, "profile"), required: booleanField(auth, "required", false, "setup.auth", diagnostics), seedOnly: booleanField(auth, "seedOnly", false, "setup.auth", diagnostics), ...optionalString(auth, "role") ? { role: optionalString(auth, "role") } : {} };
|
|
383
411
|
const seed = objectField(record, "seed", "setup", diagnostics);
|
|
384
412
|
if (seed) {
|
|
385
413
|
const environments = (arrayField(seed, "environments", "setup.seed", diagnostics) ?? [targetEnvironment]).map((entry, index) => parseEnvironment(entry, `setup.seed.environments[${index}]`, diagnostics, targetEnvironment));
|
|
@@ -970,6 +998,7 @@ function parseTreeseedSceneManifest(value, diagnostics) {
|
|
|
970
998
|
title,
|
|
971
999
|
description: optionalString(value, "description"),
|
|
972
1000
|
audience: stringArrayField(value, "audience", "manifest", diagnostics),
|
|
1001
|
+
journey: parseJourney(value, diagnostics),
|
|
973
1002
|
mode,
|
|
974
1003
|
target,
|
|
975
1004
|
devices,
|
package/dist/scenes/types.d.ts
CHANGED
|
@@ -99,6 +99,7 @@ export type TreeseedSceneSetup = {
|
|
|
99
99
|
profile?: string;
|
|
100
100
|
required: boolean;
|
|
101
101
|
role?: TreeseedSceneVisualAuditRole;
|
|
102
|
+
seedOnly?: boolean;
|
|
102
103
|
};
|
|
103
104
|
seed?: {
|
|
104
105
|
name?: string;
|
|
@@ -393,6 +394,20 @@ export type TreeseedSceneManifest = {
|
|
|
393
394
|
title: string;
|
|
394
395
|
description?: string;
|
|
395
396
|
audience: string[];
|
|
397
|
+
journey?: {
|
|
398
|
+
kind: 'service' | 'page' | 'visual-audit';
|
|
399
|
+
proves?: string[];
|
|
400
|
+
minimumSteps?: number;
|
|
401
|
+
requiresInteractiveAction?: boolean;
|
|
402
|
+
producesState?: Array<{
|
|
403
|
+
key: string;
|
|
404
|
+
kind: string;
|
|
405
|
+
}>;
|
|
406
|
+
consumesState?: Array<{
|
|
407
|
+
key: string;
|
|
408
|
+
kind: string;
|
|
409
|
+
}>;
|
|
410
|
+
};
|
|
396
411
|
mode: TreeseedSceneMode;
|
|
397
412
|
target: TreeseedSceneTarget;
|
|
398
413
|
devices: TreeseedSceneDeviceConfig;
|
|
@@ -469,6 +484,8 @@ export type TreeseedSceneRunOptions = {
|
|
|
469
484
|
scene: string | TreeseedSceneManifest;
|
|
470
485
|
environment?: TreeseedSceneEnvironment;
|
|
471
486
|
device?: TreeseedSceneDeviceProfileId;
|
|
487
|
+
browser?: TreeseedSceneBrowser;
|
|
488
|
+
authRole?: TreeseedSceneVisualAuditRole;
|
|
472
489
|
record?: boolean;
|
|
473
490
|
artifactMode?: 'full' | 'screenshots';
|
|
474
491
|
runId?: string;
|
|
@@ -491,6 +508,8 @@ export type TreeseedSceneDeviceMatrixOptions = {
|
|
|
491
508
|
scene: string;
|
|
492
509
|
environment?: TreeseedSceneEnvironment;
|
|
493
510
|
devices?: TreeseedSceneDeviceProfileId[];
|
|
511
|
+
browser?: TreeseedSceneBrowser;
|
|
512
|
+
authRole?: TreeseedSceneVisualAuditRole;
|
|
494
513
|
record?: boolean;
|
|
495
514
|
artifactMode?: 'full' | 'screenshots';
|
|
496
515
|
mode?: TreeseedSceneExecutionMode;
|
|
@@ -967,6 +986,7 @@ export type TreeseedSceneOperationWaitOptions = {
|
|
|
967
986
|
projectRoot: string;
|
|
968
987
|
scene: TreeseedSceneManifest;
|
|
969
988
|
environment: TreeseedSceneEnvironment;
|
|
989
|
+
runId: string;
|
|
970
990
|
baseUrl: string;
|
|
971
991
|
spec: TreeseedSceneOperationWaitSpec;
|
|
972
992
|
linkedOperationIds?: string[];
|
|
@@ -1812,9 +1832,12 @@ export type TreeseedSceneLocator = {
|
|
|
1812
1832
|
};
|
|
1813
1833
|
export type TreeseedScenePage = {
|
|
1814
1834
|
goto(url: string, options?: {
|
|
1815
|
-
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
|
1835
|
+
waitUntil?: 'load' | 'domcontentLoaded' | 'domcontentloaded' | 'networkidle';
|
|
1816
1836
|
timeout?: number;
|
|
1817
|
-
}): Promise<
|
|
1837
|
+
}): Promise<{
|
|
1838
|
+
status(): number;
|
|
1839
|
+
url(): string;
|
|
1840
|
+
} | null | undefined>;
|
|
1818
1841
|
url(): string;
|
|
1819
1842
|
locator(selector: string): TreeseedSceneLocator;
|
|
1820
1843
|
getByTestId(testId: string): TreeseedSceneLocator;
|
|
@@ -52,10 +52,11 @@ function configuredValue(input, name) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
function serviceHeaders(input) {
|
|
55
|
+
const localDevServiceSecret = input?.environment === "local" ? "treeseed-web-service-dev-secret" : null;
|
|
55
56
|
return {
|
|
56
57
|
"content-type": "application/json",
|
|
57
58
|
"x-treeseed-service-id": configuredValue(input, "TREESEED_API_WEB_SERVICE_ID") ?? configuredValue(input, "TREESEED_WEB_SERVICE_ID") ?? "web",
|
|
58
|
-
"x-treeseed-service-secret": configuredValue(input, "TREESEED_API_WEB_SERVICE_SECRET") ?? configuredValue(input, "TREESEED_WEB_SERVICE_SECRET") ?? "treeseed-web-service-dev-secret"
|
|
59
|
+
"x-treeseed-service-secret": process.env.TREESEED_API_WEB_SERVICE_SECRET?.trim() ?? process.env.TREESEED_WEB_SERVICE_SECRET?.trim() ?? localDevServiceSecret ?? configuredValue(input, "TREESEED_API_WEB_SERVICE_SECRET") ?? configuredValue(input, "TREESEED_WEB_SERVICE_SECRET") ?? "treeseed-web-service-dev-secret"
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
async function sleep(ms) {
|
|
@@ -123,12 +124,13 @@ async function seedVisualAuditFixtures(input) {
|
|
|
123
124
|
async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
|
|
124
125
|
const diagnostics = [];
|
|
125
126
|
const roles = [...new Set(input.roles)].filter((role) => role !== "anonymous");
|
|
126
|
-
|
|
127
|
+
const seedDiagnostics = await seedVisualAuditFixtures({
|
|
127
128
|
baseUrl: input.baseUrl,
|
|
128
129
|
roles,
|
|
129
130
|
projectRoot: input.projectRoot,
|
|
130
131
|
environment: input.environment
|
|
131
|
-
})
|
|
132
|
+
});
|
|
133
|
+
let roleSetupFailed = false;
|
|
132
134
|
for (const role of roles) {
|
|
133
135
|
const user = treeseedSceneVisualAuditUserForRole(role);
|
|
134
136
|
if (!user) continue;
|
|
@@ -138,6 +140,7 @@ async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
|
|
|
138
140
|
continue;
|
|
139
141
|
} catch (error) {
|
|
140
142
|
if (!isAuthFailure(error)) {
|
|
143
|
+
roleSetupFailed = true;
|
|
141
144
|
diagnostics.push(sceneWarningDiagnostic(
|
|
142
145
|
"scene.visual_audit_fixture_unavailable",
|
|
143
146
|
`Visual audit fixture setup for ${role} failed against ${input.baseUrl}: ${error instanceof Error ? error.message : String(error ?? "local fixture API is unavailable")}. Authenticated screenshots require the local API and database to be healthy.`,
|
|
@@ -160,12 +163,14 @@ async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
|
|
|
160
163
|
if (payload.confirmationToken) {
|
|
161
164
|
await client.confirmWebEmail({ token: payload.confirmationToken });
|
|
162
165
|
} else if (payload.confirmationRequired) {
|
|
166
|
+
roleSetupFailed = true;
|
|
163
167
|
diagnostics.push(sceneWarningDiagnostic("scene.visual_audit_fixture_unavailable", `Visual audit fixture user ${user.email} requires email confirmation, but the local API did not return a confirmation token.`, "roles"));
|
|
164
168
|
}
|
|
165
169
|
} catch (error) {
|
|
166
170
|
try {
|
|
167
171
|
await client.webSignIn({ login: user.email, password: user.password });
|
|
168
172
|
} catch {
|
|
173
|
+
roleSetupFailed = true;
|
|
169
174
|
diagnostics.push(sceneWarningDiagnostic(
|
|
170
175
|
"scene.visual_audit_fixture_unavailable",
|
|
171
176
|
`Visual audit fixture setup for ${role} failed against ${input.baseUrl}: ${error instanceof Error ? error.message : String(error ?? "local fixture API is unavailable")}. Authenticated screenshots require the local API and database to be healthy.`,
|
|
@@ -174,6 +179,7 @@ async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
|
|
|
174
179
|
}
|
|
175
180
|
}
|
|
176
181
|
}
|
|
182
|
+
if (roleSetupFailed) diagnostics.unshift(...seedDiagnostics);
|
|
177
183
|
return diagnostics;
|
|
178
184
|
}
|
|
179
185
|
async function signInTreeseedSceneVisualAuditRole(input) {
|