@bugmole/cli 0.5.0 → 0.6.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/package.json +5 -2
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +233 -46
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +22 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +9 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +477 -174
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.ts +4 -4
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +21 -0
- package/src/registry-worker/runner/tokens.ts +8 -0
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/appium-driver.ts +1 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +67 -6
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +22 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +39 -2
- package/src/runtime/journey-graph.test.ts +18 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +21 -2
- package/src/runtime/playwright-driver.ts +49 -7
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +47 -4
- package/src/runtime/site-discovery.ts +79 -9
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.ts +5 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
package/src/runtime/pipeline.ts
CHANGED
|
@@ -22,7 +22,10 @@ import path from "node:path";
|
|
|
22
22
|
import YAML from "yaml";
|
|
23
23
|
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
24
24
|
import { projectPrefix } from "../storage/keys.js";
|
|
25
|
-
import {
|
|
25
|
+
import { testCaseResultsKey } from "../storage/test-case-results.js";
|
|
26
|
+
import { IN_REVIEW, MAX_TASK_ATTEMPTS } from "../registry/task-scheduling.js";
|
|
27
|
+
import { suggestedRoleNames, usableRoleNames, type RoleRecord } from "../registry/roles.js";
|
|
28
|
+
import { readSpecRoles } from "./project-roles.js";
|
|
26
29
|
import { driverForPlatform } from "./planner.js";
|
|
27
30
|
|
|
28
31
|
export type PipelineStage = "explore" | "roles" | "audit" | "testcases" | "plan" | "run";
|
|
@@ -50,8 +53,10 @@ export type PipelineTask = {
|
|
|
50
53
|
export type PipelineInputs = {
|
|
51
54
|
projectId: string;
|
|
52
55
|
journeys: PipelineJourney[];
|
|
53
|
-
/**
|
|
56
|
+
/** Confirmed roles: the only actors a flow may be planned for. */
|
|
54
57
|
knownActors: string[];
|
|
58
|
+
/** Roles Bugmole suggested that still wait for a person to confirm or reject. */
|
|
59
|
+
suggestedActors?: string[];
|
|
55
60
|
/** Journey ids that already have a written test-case set. */
|
|
56
61
|
caseSetJourneyIds: string[];
|
|
57
62
|
/** Journey ids that already have an App Audit artifact. */
|
|
@@ -168,7 +173,7 @@ function activeTask(tasks: PipelineTask[]): PipelineTask | undefined {
|
|
|
168
173
|
*/
|
|
169
174
|
export function derivePipelineState(inputs: PipelineInputs): PipelineState {
|
|
170
175
|
const {
|
|
171
|
-
projectId, journeys, knownActors, caseSetJourneyIds,
|
|
176
|
+
projectId, journeys, knownActors, suggestedActors = [], caseSetJourneyIds,
|
|
172
177
|
plannedJourneyIds, tasks, hardStopCategories, recordedBlockerCategories,
|
|
173
178
|
} = inputs;
|
|
174
179
|
|
|
@@ -237,8 +242,8 @@ export function derivePipelineState(inputs: PipelineInputs): PipelineState {
|
|
|
237
242
|
stage: "roles",
|
|
238
243
|
satisfied: missingActors.length === 0,
|
|
239
244
|
detail: missingActors.length
|
|
240
|
-
? `
|
|
241
|
-
: "every flow actor is
|
|
245
|
+
? `unconfirmed role${missingActors.length === 1 ? "" : "s"}: ${missingActors.join(", ")}`
|
|
246
|
+
: "every flow actor is a confirmed role",
|
|
242
247
|
},
|
|
243
248
|
{
|
|
244
249
|
// The audit inventories each screen's controls and runs the component
|
|
@@ -331,6 +336,33 @@ export function derivePipelineState(inputs: PipelineInputs): PipelineState {
|
|
|
331
336
|
};
|
|
332
337
|
}
|
|
333
338
|
|
|
339
|
+
// Finished work waiting for a person is not in flight, but the next stage
|
|
340
|
+
// would build on it before anyone looked. Wait for the review instead.
|
|
341
|
+
const inReview = tasks.filter((task) => task.status === IN_REVIEW);
|
|
342
|
+
if (inReview.length) {
|
|
343
|
+
const first = inReview[0];
|
|
344
|
+
return {
|
|
345
|
+
status: "paused",
|
|
346
|
+
reason: `${inReview.length} task${inReview.length === 1 ? " is" : "s are"} waiting for your review — `
|
|
347
|
+
+ `accept or send back "${first.title ?? first.idempotencyKey ?? "the task"}" on the Agent Tasks board.`,
|
|
348
|
+
stages,
|
|
349
|
+
orphanedArtifacts,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A flow's actor that Bugmole suggested but nobody has confirmed yet is a
|
|
354
|
+
// question for a person, not work for an agent: queueing the roles stage
|
|
355
|
+
// again would only re-suggest it.
|
|
356
|
+
const awaitingDecision = missingActors.filter((actor) => suggestedActors.includes(actor));
|
|
357
|
+
if (awaitingDecision.length && awaitingDecision.length === missingActors.length) {
|
|
358
|
+
return {
|
|
359
|
+
status: "paused",
|
|
360
|
+
reason: `confirm or reject the suggested role${awaitingDecision.length === 1 ? "" : "s"} ${awaitingDecision.join(", ")} on the Roles page (Manage → Roles). Only confirmed roles are planned for.`,
|
|
361
|
+
stages,
|
|
362
|
+
orphanedArtifacts,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
334
366
|
// Only one stage runs at a time; the worker already serialises task
|
|
335
367
|
// execution, and queueing ahead would just create work that races.
|
|
336
368
|
const inFlight = activeTask(tasks);
|
|
@@ -438,13 +470,14 @@ function buildNextTask(
|
|
|
438
470
|
return {
|
|
439
471
|
stage,
|
|
440
472
|
taskType: "code",
|
|
441
|
-
title: "
|
|
473
|
+
title: "Suggest the roles the discovered flows use",
|
|
442
474
|
description: [
|
|
443
|
-
`The discovered flows use actors that
|
|
444
|
-
"Planning
|
|
445
|
-
"Inspect the application's real roles
|
|
446
|
-
"
|
|
447
|
-
"
|
|
475
|
+
`The discovered flows use actors that are not confirmed roles: ${context.missingActors.join(", ")}.`,
|
|
476
|
+
"Planning only uses roles a person confirmed, so the pipeline cannot proceed until this is reconciled.",
|
|
477
|
+
"Inspect the application's real roles (sign-in walls, role-gated routes and menus) and either suggest",
|
|
478
|
+
"the missing role with bugmole_role_propose (with the evidence that points at it) so a person can",
|
|
479
|
+
"confirm, rename or reject it, or update the flow to use a confirmed role that genuinely matches.",
|
|
480
|
+
"You cannot confirm a role yourself. Do not invent permissions to make the check pass.",
|
|
448
481
|
].join(" "),
|
|
449
482
|
idempotencyKey: pipelineIdempotencyKey(projectId, stage),
|
|
450
483
|
};
|
|
@@ -521,12 +554,14 @@ function readYaml(filePath: string): any {
|
|
|
521
554
|
}
|
|
522
555
|
}
|
|
523
556
|
|
|
524
|
-
/**
|
|
525
|
-
export function knownActors(specDir: string): string[] {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
557
|
+
/** Confirmed roles, which the planner validates against: roles.yaml, overridden by the registry's decisions. */
|
|
558
|
+
export function knownActors(specDir: string, registryRoles: RoleRecord[] = []): string[] {
|
|
559
|
+
return usableRoleNames(readSpecRoles(specDir), registryRoles);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Roles still waiting for a person's decision. */
|
|
563
|
+
export function suggestedActors(specDir: string, registryRoles: RoleRecord[] = []): string[] {
|
|
564
|
+
return suggestedRoleNames(readSpecRoles(specDir), registryRoles);
|
|
530
565
|
}
|
|
531
566
|
|
|
532
567
|
/**
|
|
@@ -569,7 +604,7 @@ async function listJourneyIdsWithVerdicts(cfg: any, projectId: string): Promise<
|
|
|
569
604
|
? caseSet.cases.map((item: any) => item?.caseId).filter((id: unknown) => typeof id === "string")
|
|
570
605
|
: [];
|
|
571
606
|
if (!journeyId || caseIds.length === 0) return null;
|
|
572
|
-
const resultKey =
|
|
607
|
+
const resultKey = testCaseResultsKey(projectId, caseSet.caseSetId);
|
|
573
608
|
const results = JSON.parse(await store(cfg).getText(resultKey));
|
|
574
609
|
// A verdict recorded against a superseded revision is not evidence
|
|
575
610
|
// about the cases as they stand now, matching how the dashboard
|
|
@@ -706,6 +741,8 @@ export async function loadPipelineInputs(
|
|
|
706
741
|
registry?: {
|
|
707
742
|
runs?: Array<{ journeyId?: string; status?: string }>;
|
|
708
743
|
plans?: Array<{ planId: string; journeyId?: string }>;
|
|
744
|
+
/** The registry's role decisions, which override roles.yaml. */
|
|
745
|
+
roles?: RoleRecord[];
|
|
709
746
|
},
|
|
710
747
|
): Promise<PipelineInputs> {
|
|
711
748
|
const projectId = cfg.project?.id ?? "default";
|
|
@@ -727,7 +764,8 @@ export async function loadPipelineInputs(
|
|
|
727
764
|
return {
|
|
728
765
|
projectId,
|
|
729
766
|
journeys,
|
|
730
|
-
knownActors: knownActors(specDir),
|
|
767
|
+
knownActors: knownActors(specDir, registry?.roles ?? []),
|
|
768
|
+
suggestedActors: suggestedActors(specDir, registry?.roles ?? []),
|
|
731
769
|
caseSetJourneyIds,
|
|
732
770
|
auditedJourneyIds,
|
|
733
771
|
plannedJourneyIds: plannedJourneyIds(specDir, journeys.map((journey) => journey.id)),
|
|
@@ -758,6 +796,7 @@ type ReconcileClient = {
|
|
|
758
796
|
}): Promise<unknown>;
|
|
759
797
|
listRuns?(): Promise<{ runs: any[] }>;
|
|
760
798
|
listPlans?(): Promise<{ plans: any[] }>;
|
|
799
|
+
listRoles?(): Promise<{ roles: Array<{ name: string; status: RoleRecord["status"] }> }>;
|
|
761
800
|
createRun?(input: {
|
|
762
801
|
planId: string;
|
|
763
802
|
idempotencyKey: string;
|
|
@@ -799,14 +838,16 @@ export async function publishPipelineState(cfg: any, state: PipelineState): Prom
|
|
|
799
838
|
}
|
|
800
839
|
|
|
801
840
|
export async function reconcilePipeline(cfg: any, client: ReconcileClient): Promise<ReconcileResult> {
|
|
802
|
-
const [listed, runs, plans] = await Promise.all([
|
|
841
|
+
const [listed, runs, plans, roles] = await Promise.all([
|
|
803
842
|
client.listTasks(),
|
|
804
843
|
client.listRuns?.().catch(() => ({ runs: [] })) ?? Promise.resolve({ runs: [] }),
|
|
805
844
|
client.listPlans?.().catch(() => ({ plans: [] })) ?? Promise.resolve({ plans: [] }),
|
|
845
|
+
client.listRoles?.().catch(() => ({ roles: [] })) ?? Promise.resolve({ roles: [] }),
|
|
806
846
|
]);
|
|
807
847
|
const inputs = await loadPipelineInputs(cfg, listed.tasks ?? [], {
|
|
808
848
|
runs: runs.runs ?? [],
|
|
809
849
|
plans: plans.plans ?? [],
|
|
850
|
+
roles: (roles.roles ?? []).map((role) => ({ name: role.name, status: role.status })),
|
|
810
851
|
});
|
|
811
852
|
const state = derivePipelineState(inputs);
|
|
812
853
|
// Published on every pass so the dashboard reflects the live position,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { driverForPlatform } from "./planner.js";
|
|
3
|
+
import { driverForPlatform, journeyFlow } from "./planner.js";
|
|
4
|
+
import { parseMaestroStyleFlow } from "./flow-language.js";
|
|
4
5
|
|
|
5
6
|
test("a web plan is never pinned to the mobile driver", () => {
|
|
6
7
|
// bugmole.config defaults execution.driver to "maestro" while the plan
|
|
@@ -35,3 +36,25 @@ test("an unknown platform is treated as web rather than defaulting to mobile", (
|
|
|
35
36
|
// first place: the driver that cannot run without a device file.
|
|
36
37
|
assert.equal(driverForPlatform(undefined, "desktop"), "playwright");
|
|
37
38
|
});
|
|
39
|
+
|
|
40
|
+
test("a journey becomes a flow that opens its first screen and follows each transition", () => {
|
|
41
|
+
const flow = journeyFlow(
|
|
42
|
+
{ id: "sign-up", name: "Sign up", startNodeId: "screen:/", nodeIds: ["screen:/", "screen:/signup", "screen:/projects/:id"], edgeIds: ["a", "b"] },
|
|
43
|
+
[
|
|
44
|
+
{ id: "screen:/", route: "/" },
|
|
45
|
+
{ id: "screen:/signup", route: "/signup" },
|
|
46
|
+
{ id: "screen:/projects/:id", route: "/projects/:id" },
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
{ id: "a", from: "screen:/", to: "screen:/signup" },
|
|
50
|
+
{ id: "b", from: "screen:/signup", to: "screen:/projects/:id", label: "Create account" },
|
|
51
|
+
],
|
|
52
|
+
);
|
|
53
|
+
assert.ok(flow);
|
|
54
|
+
const parsed = parseMaestroStyleFlow(flow, "http://localhost:3000");
|
|
55
|
+
assert.deepEqual(parsed.steps.map((step) => step.type), ["launchApp", "assertLayout", "openLink", "assertLayout", "tapOn", "assertLayout"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a journey with no openable screen has no flow", () => {
|
|
59
|
+
assert.equal(journeyFlow({ id: "x", nodeIds: ["n"] }, [{ id: "n", route: "/projects/:id" }], []), null);
|
|
60
|
+
});
|
package/src/runtime/planner.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
|
5
5
|
import { projectPrefix } from "../storage/keys.js";
|
|
6
6
|
import { ControlPlaneClient } from "../registry/control-plane-client.js";
|
|
7
7
|
import { nextJourneyTask } from "./journey-editor.js";
|
|
8
|
+
import { projectActorProblem, projectRoles, rolesClient } from "./project-roles.js";
|
|
8
9
|
|
|
9
10
|
/** Drivers that can actually operate each kind of target. */
|
|
10
11
|
const DRIVERS_BY_PLATFORM: Record<string, string[]> = {
|
|
@@ -36,6 +37,8 @@ interface PlanOptions {
|
|
|
36
37
|
journeyId: string;
|
|
37
38
|
actor: string;
|
|
38
39
|
revision?: number;
|
|
40
|
+
/** Where the registry's role decisions come from; null skips the registry. Defaults to this machine's API key. */
|
|
41
|
+
rolesClient?: Pick<ControlPlaneClient, "listRoles"> | null;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
interface PlanResult {
|
|
@@ -82,17 +85,10 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
|
|
|
82
85
|
nextSteps: ["Select an existing journey or record the selected journey before planning."],
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
nextSteps: [
|
|
90
|
-
`Run the canonical flow "${journeyId}"`,
|
|
91
|
-
"Adjust the flow for any failures and rerun it",
|
|
92
|
-
"Mark the verified run as the stabilized canonical flow",
|
|
93
|
-
],
|
|
94
|
-
};
|
|
95
|
-
}
|
|
88
|
+
// A draft journey can be planned: running its plan is how it becomes
|
|
89
|
+
// stable (bugmole_journey_stabilize takes that run's ID). Requiring
|
|
90
|
+
// "stable" first left no way to make the run that proves it.
|
|
91
|
+
const isStable = selectedJourney.stability === "stable" && selectedJourney.linkedToGraph === true;
|
|
96
92
|
const flowState = nextJourneyTask(cfg, journeyId);
|
|
97
93
|
if (flowState.status === "actionable") {
|
|
98
94
|
return {
|
|
@@ -123,17 +119,18 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
|
|
|
123
119
|
nextSteps: ["Create roles.yaml from the guided testing workflow, then retry planning."],
|
|
124
120
|
};
|
|
125
121
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
122
|
+
// Only a role a person confirmed may be planned for: Bugmole suggests
|
|
123
|
+
// roles, people decide them (src/registry/roles.ts, docs/public/roles.md).
|
|
124
|
+
const roles = await projectRoles(cfg, options.rolesClient === undefined ? rolesClient(cfg) : options.rolesClient);
|
|
125
|
+
const actorIssue = projectActorProblem(roles, actor);
|
|
126
|
+
if (actorIssue) {
|
|
131
127
|
return {
|
|
132
128
|
status: "error",
|
|
133
|
-
error:
|
|
129
|
+
error: actorIssue,
|
|
134
130
|
nextSteps: [
|
|
135
|
-
`
|
|
136
|
-
|
|
131
|
+
`Plan with a confirmed role: ${roles.usable.join(", ") || "(none confirmed yet)"}`,
|
|
132
|
+
...(roles.suggested.length ? [`Ask a person to confirm or reject the suggested roles on the dashboard's Roles page: ${roles.suggested.join(", ")}`] : []),
|
|
133
|
+
"If the journey really needs a new role, suggest it with bugmole_role_propose and wait for a person to confirm it",
|
|
137
134
|
],
|
|
138
135
|
};
|
|
139
136
|
}
|
|
@@ -169,6 +166,25 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
|
|
|
169
166
|
const steps = graphSteps;
|
|
170
167
|
const planId = journeyId.replace(/[^a-z0-9._-]/gi, "_").toLowerCase();
|
|
171
168
|
const planPath = path.join(plansDir, `${planId}.flow.yaml`);
|
|
169
|
+
const platform = cfg.project?.platform || "web";
|
|
170
|
+
// Without a flow a plan has nothing to run. On the web, write one from
|
|
171
|
+
// the journey itself: open its first screen, then follow each recorded
|
|
172
|
+
// transition, checking every screen it reaches.
|
|
173
|
+
const configuredFlow: string | undefined = cfg.execution?.maestro?.flow || undefined;
|
|
174
|
+
let generatedFlow: string | null = null;
|
|
175
|
+
if (!configuredFlow && platform === "web") {
|
|
176
|
+
const reference = `flows/journeys/${planId}.yaml`;
|
|
177
|
+
if (fs.existsSync(path.join(specDir, reference))) {
|
|
178
|
+
generatedFlow = reference;
|
|
179
|
+
} else {
|
|
180
|
+
const content = journeyFlow(selectedJourney, discoveredNodes, discoveredEdges);
|
|
181
|
+
if (content) {
|
|
182
|
+
generatedFlow = reference;
|
|
183
|
+
fs.mkdirSync(path.join(specDir, "flows", "journeys"), { recursive: true });
|
|
184
|
+
fs.writeFileSync(path.join(specDir, reference), content, "utf-8");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
172
188
|
|
|
173
189
|
const plan = {
|
|
174
190
|
version: 2,
|
|
@@ -176,14 +192,14 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
|
|
|
176
192
|
name: `Journey: ${journeyId}`,
|
|
177
193
|
description: `Generated plan for journey ${journeyId} with actor ${actor}`,
|
|
178
194
|
driver: driverForPlatform(cfg.execution?.driver, cfg.project?.platform || "web"),
|
|
179
|
-
platform
|
|
195
|
+
platform,
|
|
180
196
|
target: {
|
|
181
197
|
base_url: cfg.project?.base_url || cfg.execution?.playwright?.base_url,
|
|
182
198
|
app_id: cfg.project?.app_id,
|
|
183
199
|
device_id: cfg.project?.device_id,
|
|
184
200
|
serial: cfg.project?.serial,
|
|
185
201
|
},
|
|
186
|
-
flow:
|
|
202
|
+
flow: configuredFlow || generatedFlow,
|
|
187
203
|
recovery: {
|
|
188
204
|
enabled: cfg.execution?.recovery?.enabled === true,
|
|
189
205
|
max_attempts: cfg.execution?.recovery?.max_attempts ?? 1,
|
|
@@ -200,7 +216,7 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
|
|
|
200
216
|
actor: actor,
|
|
201
217
|
goal: `Complete journey: ${journeyId}`,
|
|
202
218
|
capabilities_required: [],
|
|
203
|
-
preconditions: [
|
|
219
|
+
preconditions: [`Signed in as ${actor}, or signed out if that's who the journey is for`],
|
|
204
220
|
steps,
|
|
205
221
|
},
|
|
206
222
|
],
|
|
@@ -263,7 +279,13 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
|
|
|
263
279
|
status: "success",
|
|
264
280
|
planId: planId,
|
|
265
281
|
planPath: `plans/${planId}.flow.yaml`,
|
|
266
|
-
|
|
282
|
+
...(plan.flow ? { flow: plan.flow } : {}),
|
|
283
|
+
message: `Plan created at ${planPath}${generatedFlow ? `, with its flow at ${path.join(specDir, generatedFlow)}` : ""}.`
|
|
284
|
+
+ (plan.flow ? "" : " It has no flow to run yet: set execution.maestro.flow, or record the journey's screens with their routes and plan again.")
|
|
285
|
+
+ (apiKey && registryUrl ? " Published to the dashboard." : ""),
|
|
286
|
+
nextSteps: isStable
|
|
287
|
+
? [`Run it: bugmole_run with planId ${planId}`]
|
|
288
|
+
: [`Run it: bugmole_run with planId ${planId}`, `When that run passes, call bugmole_journey_stabilize with journeyId ${journeyId} and the run's ID`],
|
|
267
289
|
};
|
|
268
290
|
} catch (error: any) {
|
|
269
291
|
return {
|
|
@@ -272,3 +294,46 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
|
|
|
272
294
|
};
|
|
273
295
|
}
|
|
274
296
|
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A web flow that walks a journey: open the first screen, then follow each
|
|
300
|
+
* transition in order, tapping its label when it has one and opening the
|
|
301
|
+
* next screen's route otherwise. Null when there is nothing to open.
|
|
302
|
+
*/
|
|
303
|
+
export function journeyFlow(
|
|
304
|
+
journey: { id: string; name?: string; startNodeId?: string; nodeIds?: string[]; edgeIds?: string[] },
|
|
305
|
+
nodes: Array<{ id: string; route?: string; title?: string }>,
|
|
306
|
+
edges: Array<{ id: string; from: string; to: string; label?: string }>,
|
|
307
|
+
): string | null {
|
|
308
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
309
|
+
const opens = (route?: string) => typeof route === "string" && route.startsWith("/") && !/[:\[*]/.test(route);
|
|
310
|
+
const quote = (text: string) => JSON.stringify(text);
|
|
311
|
+
const startId = journey.startNodeId || journey.nodeIds?.[0];
|
|
312
|
+
const start = startId ? byId.get(startId) : undefined;
|
|
313
|
+
const steps: string[] = ["- launchApp"];
|
|
314
|
+
if (start && opens(start.route) && start.route !== "/") steps.push(`- openLink: ${quote(start.route!)}`);
|
|
315
|
+
else if (!start || !opens(start.route)) {
|
|
316
|
+
const first = (journey.nodeIds ?? []).map((id) => byId.get(id)).find((node) => opens(node?.route));
|
|
317
|
+
if (!first) return null;
|
|
318
|
+
if (first.route !== "/") steps.push(`- openLink: ${quote(first.route!)}`);
|
|
319
|
+
}
|
|
320
|
+
steps.push("- assertLayout");
|
|
321
|
+
const journeyEdges = (journey.edgeIds ?? []).map((id) => edges.find((edge) => edge.id === id)).filter((edge): edge is NonNullable<typeof edge> => Boolean(edge));
|
|
322
|
+
for (const edge of journeyEdges) {
|
|
323
|
+
const target = byId.get(edge.to);
|
|
324
|
+
const label = typeof edge.label === "string" ? edge.label.trim() : "";
|
|
325
|
+
if (label && label.length <= 80 && !/^(navigate|dom|link)[:-]/i.test(label)) steps.push(`- tapOn: ${quote(label)}`);
|
|
326
|
+
else if (opens(target?.route)) steps.push(`- openLink: ${quote(target!.route!)}`);
|
|
327
|
+
else continue;
|
|
328
|
+
steps.push("- assertLayout");
|
|
329
|
+
}
|
|
330
|
+
return [
|
|
331
|
+
`# Written by bugmole_plan from journey ${journey.id}. Edit it freely:`,
|
|
332
|
+
"# the plan runs this file, and planning again won't overwrite your changes",
|
|
333
|
+
"# unless you delete it.",
|
|
334
|
+
`name: ${quote(journey.name || journey.id)}`,
|
|
335
|
+
"---",
|
|
336
|
+
...steps,
|
|
337
|
+
"",
|
|
338
|
+
].join("\n");
|
|
339
|
+
}
|
|
@@ -97,7 +97,7 @@ test("a worker without Playwright's ffmpeg loses the video, not the run", () =>
|
|
|
97
97
|
import http from "node:http";
|
|
98
98
|
import type { AddressInfo } from "node:net";
|
|
99
99
|
import { chromium } from "playwright";
|
|
100
|
-
import { brokenStatus, checkLayout, checkLinks } from "./playwright-driver.js";
|
|
100
|
+
import { brokenStatus, checkLayout, checkLinks, hideFocusedFieldText } from "./playwright-driver.js";
|
|
101
101
|
|
|
102
102
|
async function browserOrSkip(t: { skip: (m: string) => void }) {
|
|
103
103
|
try {
|
|
@@ -152,7 +152,10 @@ test("assertLinks reports links that don't answer, and doesn't count robots bein
|
|
|
152
152
|
assert.equal(brokenStatus(403, false), false);
|
|
153
153
|
assert.equal(brokenStatus(429, false), false);
|
|
154
154
|
assert.equal(brokenStatus(410, false), true);
|
|
155
|
-
assert.equal(brokenStatus(503, false),
|
|
155
|
+
assert.equal(brokenStatus(503, false), false);
|
|
156
|
+
assert.equal(brokenStatus(504, false), false);
|
|
157
|
+
assert.equal(brokenStatus(500, false), true);
|
|
158
|
+
assert.equal(brokenStatus(503, true), true);
|
|
156
159
|
const browser = await browserOrSkip(t);
|
|
157
160
|
if (!browser) return;
|
|
158
161
|
const unsafeHits: string[] = [];
|
|
@@ -236,3 +239,19 @@ test("assertClickable scrolls to a button below the fold, but still fails one th
|
|
|
236
239
|
}
|
|
237
240
|
});
|
|
238
241
|
|
|
242
|
+
|
|
243
|
+
test("a secret typed into a plain text field is invisible on screen but keeps its value", async (t) => {
|
|
244
|
+
const browser = await browserOrSkip(t);
|
|
245
|
+
if (!browser) return;
|
|
246
|
+
try {
|
|
247
|
+
const page = await browser.newPage();
|
|
248
|
+
await page.setContent(`<input id="token" type="text" style="color:#111">`);
|
|
249
|
+
await page.focus("#token");
|
|
250
|
+
await hideFocusedFieldText(page);
|
|
251
|
+
await page.keyboard.type("s3cr3t-value");
|
|
252
|
+
assert.equal(await page.inputValue("#token"), "s3cr3t-value");
|
|
253
|
+
assert.equal(await page.evaluate(`getComputedStyle(document.getElementById("token")).color`), "rgba(0, 0, 0, 0)");
|
|
254
|
+
} finally {
|
|
255
|
+
await browser.close();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
@@ -20,11 +20,13 @@ import {
|
|
|
20
20
|
describeStepLabel,
|
|
21
21
|
isPatternText,
|
|
22
22
|
parseMaestroStyleFlow,
|
|
23
|
+
referencedSecretNames,
|
|
23
24
|
resolveFlowUrl,
|
|
24
25
|
type FlowStep,
|
|
25
26
|
type ParsedFlow,
|
|
26
27
|
} from "./flow-language.js";
|
|
27
28
|
import { analyzeFailure, rawFlowSteps, type FailureContext } from "./failure-analysis.js";
|
|
29
|
+
import { redactSecrets } from "./secret-redaction.js";
|
|
28
30
|
import { createTempEmailInbox, deleteTempEmailInbox, extractFirstLink, waitForTempEmailMessage, type TempEmailInbox } from "../integrations/temp-email.js";
|
|
29
31
|
|
|
30
32
|
export { parseMaestroStyleFlow } from "./flow-language.js";
|
|
@@ -363,9 +365,30 @@ export function isUnsafeLink(href: string, text: string): boolean {
|
|
|
363
365
|
/** Answers that mean a link is broken, not that the site turned a robot away. */
|
|
364
366
|
export function brokenStatus(status: number, sameSite: boolean): boolean {
|
|
365
367
|
if (sameSite) return status >= 400;
|
|
368
|
+
// 502-504 from someone else's site is their gateway having a bad moment
|
|
369
|
+
// (GitHub answers 504 to bots now and then), not a link the page got wrong.
|
|
370
|
+
if (status >= 502 && status <= 504) return false;
|
|
366
371
|
return status === 404 || status === 410 || status >= 500;
|
|
367
372
|
}
|
|
368
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Makes the focused field's text invisible on screen (screenshots, video)
|
|
376
|
+
* without changing its value. Password fields are already masked.
|
|
377
|
+
*/
|
|
378
|
+
export async function hideFocusedFieldText(page: Page): Promise<void> {
|
|
379
|
+
await page.evaluate(`(() => {
|
|
380
|
+
const field = document.activeElement;
|
|
381
|
+
if (!field || field === document.body) return;
|
|
382
|
+
field.setAttribute("data-bugmole-secret", "");
|
|
383
|
+
if (!document.getElementById("bugmole-secret-style")) {
|
|
384
|
+
const style = document.createElement("style");
|
|
385
|
+
style.id = "bugmole-secret-style";
|
|
386
|
+
style.textContent = "[data-bugmole-secret]{color:transparent!important;-webkit-text-fill-color:transparent!important;text-shadow:none!important;caret-color:currentColor}";
|
|
387
|
+
(document.head || document.documentElement).appendChild(style);
|
|
388
|
+
}
|
|
389
|
+
})()`).catch(() => undefined);
|
|
390
|
+
}
|
|
391
|
+
|
|
369
392
|
/** Requests every link on the page (same-site only unless `external`). Returns the broken ones. */
|
|
370
393
|
export async function checkLinks(page: Page, external: boolean, limit = 80): Promise<string[]> {
|
|
371
394
|
const origin = new URL(page.url()).origin;
|
|
@@ -388,6 +411,11 @@ export async function checkLinks(page: Page, external: boolean, limit = 80): Pro
|
|
|
388
411
|
let response = await request.head(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
|
|
389
412
|
// Plenty of servers answer HEAD badly; a GET is what a visitor sends.
|
|
390
413
|
if (response.status() >= 400) response = await request.get(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
|
|
414
|
+
// A server error can be a blip: ask once more before calling it broken.
|
|
415
|
+
if (response.status() >= 500) {
|
|
416
|
+
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
|
417
|
+
response = await request.get(url, { timeout: 10_000, maxRedirects: 5, failOnStatusCode: false });
|
|
418
|
+
}
|
|
391
419
|
if (brokenStatus(response.status(), sameSite)) broken.push(`${url} (${response.status()})`);
|
|
392
420
|
} catch (error) {
|
|
393
421
|
if (sameSite) broken.push(`${url} (no answer: ${error instanceof Error ? error.message.split("\n")[0] : String(error)})`);
|
|
@@ -502,6 +530,8 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
502
530
|
const videoDir = path.join(artifactsDir, "video");
|
|
503
531
|
await fs.mkdir(videoDir, { recursive: true });
|
|
504
532
|
const log: string[] = [];
|
|
533
|
+
const typesSecret = referencedSecretNames(flowContent).length > 0;
|
|
534
|
+
if (typesSecret) log.push("This flow types a secret, so no Playwright trace is recorded.");
|
|
505
535
|
const steps: DriverStepResult[] = [];
|
|
506
536
|
const pageErrors: string[] = [];
|
|
507
537
|
const consoleErrors: string[] = [];
|
|
@@ -519,7 +549,7 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
519
549
|
|
|
520
550
|
const writeLog = async () => {
|
|
521
551
|
const logPath = path.join(artifactsDir, "playwright.log");
|
|
522
|
-
await fs.writeFile(logPath, log.join("\n"), "utf8").catch(() => undefined);
|
|
552
|
+
await fs.writeFile(logPath, redactSecrets(log.join("\n")), "utf8").catch(() => undefined);
|
|
523
553
|
return logPath;
|
|
524
554
|
};
|
|
525
555
|
const stopTrace = async (): Promise<string | undefined> => {
|
|
@@ -549,6 +579,7 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
549
579
|
};
|
|
550
580
|
}
|
|
551
581
|
const storageState = context.sessionStatePath && (await fileExists(context.sessionStatePath)) ? context.sessionStatePath : undefined;
|
|
582
|
+
if (storageState && !typesSecret) log.push("This flow starts signed in, so no Playwright trace is recorded (it would hold the session cookies).");
|
|
552
583
|
// Record at the page's real size; Playwright's default shrinks to 800px.
|
|
553
584
|
const videoSize = flow.viewport ?? emulation.options.viewport ?? { width: 1280, height: 720 };
|
|
554
585
|
const contextOptions = {
|
|
@@ -561,7 +592,11 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
561
592
|
withVideo ? { ...contextOptions, recordVideo: { dir: videoDir, size: videoSize } } : contextOptions,
|
|
562
593
|
);
|
|
563
594
|
// The trace is the step-by-step record a person opens to debug a run.
|
|
564
|
-
|
|
595
|
+
// It records every typed character and field value, so a flow that
|
|
596
|
+
// types a secret gets no trace: redaction can't reach inside it.
|
|
597
|
+
// A flow that starts signed in has the session cookies in every
|
|
598
|
+
// request it records, so it gets no trace either.
|
|
599
|
+
if (!typesSecret && !storageState) await opened.tracing.start({ screenshots: true, snapshots: true }).catch(() => undefined);
|
|
565
600
|
return opened;
|
|
566
601
|
};
|
|
567
602
|
browserContext = await openContext(true);
|
|
@@ -597,7 +632,7 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
597
632
|
};
|
|
598
633
|
|
|
599
634
|
const runStep = async (rawStep: FlowStep): Promise<void> => {
|
|
600
|
-
const step = interpolateStep(rawStep, flowContext);
|
|
635
|
+
const step = interpolateStep(rawStep, flowContext, { env: { ...process.env, ...context.environment } });
|
|
601
636
|
switch (step.type) {
|
|
602
637
|
case "launchApp":
|
|
603
638
|
await page.goto(flow.url, { waitUntil: "load" });
|
|
@@ -622,6 +657,9 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
622
657
|
return;
|
|
623
658
|
}
|
|
624
659
|
case "inputText":
|
|
660
|
+
// A secret typed into a plain text field would show in every
|
|
661
|
+
// screenshot and the video. Hide that field's text before typing.
|
|
662
|
+
if (rawStep.type === "inputText" && referencedSecretNames(rawStep.text).length) await hideFocusedFieldText(page);
|
|
625
663
|
await page.keyboard.type(step.text);
|
|
626
664
|
return;
|
|
627
665
|
case "eraseText":
|
|
@@ -726,7 +764,11 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
726
764
|
}
|
|
727
765
|
|
|
728
766
|
const finalScreenshot = await capture("playwright-final");
|
|
729
|
-
if (context.sessionStatePath)
|
|
767
|
+
if (context.sessionStatePath) {
|
|
768
|
+
// The session file holds the app's cookies: readable by this user only.
|
|
769
|
+
await browserContext.storageState({ path: context.sessionStatePath });
|
|
770
|
+
await fs.chmod(context.sessionStatePath, 0o600).catch(() => undefined);
|
|
771
|
+
}
|
|
730
772
|
const tracePath = await stopTrace();
|
|
731
773
|
const logPath = await writeLog();
|
|
732
774
|
const videoPath = await page.video()?.path();
|
|
@@ -760,7 +802,7 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
760
802
|
steps,
|
|
761
803
|
};
|
|
762
804
|
} catch (error) {
|
|
763
|
-
const message = error instanceof Error ? error.message : "Playwright flow failed.";
|
|
805
|
+
const message = redactSecrets(error instanceof Error ? error.message : "Playwright flow failed.");
|
|
764
806
|
log.push(`ERROR: ${message}`);
|
|
765
807
|
// The step whose action threw failed; steps after it never ran and must
|
|
766
808
|
// not be reported as either passed or failed.
|
|
@@ -797,12 +839,12 @@ export class PlaywrightDriver implements AppDriver {
|
|
|
797
839
|
invalidFields: snapshot?.invalidFields ?? [],
|
|
798
840
|
};
|
|
799
841
|
const contextPath = path.join(artifactsDir, "failure-context.json");
|
|
800
|
-
await fs.writeFile(contextPath, `${JSON.stringify(failureContext, null, 2)}\n`, "utf8");
|
|
842
|
+
await fs.writeFile(contextPath, `${redactSecrets(JSON.stringify(failureContext, null, 2))}\n`, "utf8");
|
|
801
843
|
artifacts.push({ path: contextPath, contentType: "application/json", name: "failure-context.json" });
|
|
802
844
|
try {
|
|
803
845
|
const analysis = analyzeFailure(flow.steps, rawFlowSteps(flowContent), failureContext);
|
|
804
846
|
const analysisPath = path.join(artifactsDir, "analysis.json");
|
|
805
|
-
await fs.writeFile(analysisPath, `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
|
|
847
|
+
await fs.writeFile(analysisPath, `${redactSecrets(JSON.stringify(analysis, null, 2))}\n`, "utf8");
|
|
806
848
|
artifacts.push({ path: analysisPath, contentType: "application/json", name: "analysis.json" });
|
|
807
849
|
log.push(`ANALYSIS: ${analysis.title}`);
|
|
808
850
|
} catch (analysisError) {
|