-
Notifications
You must be signed in to change notification settings - Fork 142
feat: scoped tiers #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,10 @@ import { startReplayProxy } from "../utils/replay-proxy-server"; | |||||
| import { toViewerRunState, pushStepState } from "../utils/push-step-state"; | ||||||
| import { extractCloseArtifacts } from "../utils/extract-close-artifacts"; | ||||||
| import { loadReplayEvents } from "../utils/load-replay-events"; | ||||||
| import { writeRunResult } from "../utils/write-run-result"; | ||||||
| import { CiResultOutput, CiStepResult } from "@expect/shared/models"; | ||||||
| import { VERSION } from "../constants"; | ||||||
| import { getStepElapsedMs, getTotalElapsedMs } from "../utils/step-elapsed"; | ||||||
|
|
||||||
| const LIVE_VIEW_PORT_MIN = 50000; | ||||||
| const LIVE_VIEW_PORT_RANGE = 10000; | ||||||
|
|
@@ -175,6 +179,36 @@ const executeCore = (input: ExecuteInput) => | |||||
| yield* git.saveTestedFingerprint(); | ||||||
| } | ||||||
|
|
||||||
| const statuses = report.stepStatuses; | ||||||
| const stepResults = report.steps.map((step) => { | ||||||
| const entry = statuses.get(step.id); | ||||||
| const stepStatus = entry?.status ?? ("not-run" as const); | ||||||
| const elapsed = getStepElapsedMs(step); | ||||||
| return new CiStepResult({ | ||||||
| title: step.title, | ||||||
| status: stepStatus, | ||||||
| ...(elapsed !== undefined ? { duration_ms: elapsed } : {}), | ||||||
| ...(stepStatus === "failed" && entry?.summary ? { error: entry.summary } : {}), | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| const totalDurationMs = getTotalElapsedMs(report.steps) || durationMs; | ||||||
| const summaryParts = [`${passedCount} passed`, `${failedCount} failed`]; | ||||||
| const resultOutput = new CiResultOutput({ | ||||||
| version: VERSION, | ||||||
| status: report.status, | ||||||
| title: report.title, | ||||||
| duration_ms: totalDurationMs, | ||||||
| steps: stepResults, | ||||||
| artifacts: { | ||||||
| ...(artifacts.videoUrl ? { video: artifacts.videoUrl } : {}), | ||||||
| ...(artifacts.localReplayUrl ? { replay: artifacts.localReplayUrl } : {}), | ||||||
| }, | ||||||
| summary: `${summaryParts.join(", ")} out of ${report.steps.length} step${report.steps.length === 1 ? "" : "s"}`, | ||||||
| }); | ||||||
|
|
||||||
| yield* writeRunResult(finalExecuted.id ?? crypto.randomUUID(), resultOutput); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
|
|
||||||
| return { | ||||||
| executedPlan: finalExecuted, | ||||||
| report, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { join } from "node:path"; | |
| import { Option } from "effect"; | ||
| import { Command } from "commander"; | ||
| import { ChangesFor } from "@expect/supervisor"; | ||
| import type { ScopeTier } from "@expect/shared/models"; | ||
| import { runHeadless } from "./utils/run-test"; | ||
| import { runInit } from "./commands/init"; | ||
| import { runAddGithubAction } from "./commands/add-github-action"; | ||
|
|
@@ -34,12 +35,15 @@ const TARGETS: readonly Target[] = ["unstaged", "branch", "changes"]; | |
|
|
||
| type OutputFormat = "text" | "json"; | ||
|
|
||
| const SCOPE_TIERS: readonly ScopeTier[] = ["quick", "standard", "thorough"]; | ||
|
|
||
| interface CommanderOpts { | ||
| message?: string; | ||
| flow?: string; | ||
| yes?: boolean; | ||
| agent?: AgentBackend; | ||
| target?: Target; | ||
| scope?: ScopeTier; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| verbose?: boolean; | ||
| headed?: boolean; | ||
| noCookies?: boolean; | ||
|
|
@@ -63,6 +67,11 @@ const program = new Command() | |
| "agent provider to use (claude, codex, copilot, gemini, cursor, opencode, or droid)", | ||
| ) | ||
| .option("-t, --target <target>", "what to test: unstaged, branch, or changes", "changes") | ||
| .option( | ||
| "-s, --scope <tier>", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| "test depth: quick (one check, ~30s), standard (primary + follow-ups), thorough (full audit)", | ||
| "standard", | ||
| ) | ||
| .option("--verbose", "enable verbose logging") | ||
| .option("--headed", "show a visible browser window during tests") | ||
| .option("--no-cookies", "skip system browser cookie extraction") | ||
|
|
@@ -80,7 +89,8 @@ Examples: | |
| $ expect --headed -m "smoke test" -y run with a visible browser | ||
| $ expect --target branch test all branch changes | ||
| $ expect --target unstaged test unstaged changes | ||
| $ expect --no-cookies -m "test" -y skip system browser cookie extraction | ||
| $ expect --scope quick -m "check the button" -y fast focused test (~30s) | ||
| $ expect --scope thorough --target branch full audit before merge | ||
| $ expect -u http://localhost:3000 -m "test" -y specify dev server URL directly | ||
| $ expect watch -m "test the login flow" watch mode`, | ||
| ); | ||
|
|
@@ -113,6 +123,12 @@ const runHeadlessForTarget = async (target: Target, opts: CommanderOpts) => { | |
| ? Option.some(CI_EXECUTION_TIMEOUT_MS) | ||
| : Option.none(); | ||
|
|
||
| const scopeTier = opts.scope ?? "standard"; | ||
| if (!SCOPE_TIERS.includes(scopeTier)) { | ||
| console.error(`Unknown scope tier: ${scopeTier}. Use ${SCOPE_TIERS.join(", ")}.`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const { changesFor } = await resolveChangesFor(target); | ||
| return runHeadless({ | ||
| changesFor, | ||
|
|
@@ -123,6 +139,7 @@ const runHeadlessForTarget = async (target: Target, opts: CommanderOpts) => { | |
| ci: ciMode, | ||
| timeoutMs, | ||
| output: opts.output ?? "text", | ||
| scopeTier, | ||
| baseUrl: opts.url?.join(", "), | ||
| }); | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||
| import { Config, Effect, Option, Stream, Schema } from "effect"; | ||||||
| import { type ChangesFor, CiResultOutput, CiStepResult } from "@expect/shared/models"; | ||||||
| import { type ChangesFor, CiResultOutput, CiStepResult, type ScopeTier } from "@expect/shared/models"; | ||||||
| import { Executor, ExecutedTestPlan, Reporter, Github } from "@expect/supervisor"; | ||||||
| import { Analytics } from "@expect/shared/observability"; | ||||||
| import type { AgentBackend } from "@expect/agent"; | ||||||
|
|
@@ -13,6 +13,7 @@ import { createCiReporter } from "./ci-reporter"; | |||||
| import { writeGhaOutputs, writeGhaStepSummary } from "./gha-output"; | ||||||
| import { getStepElapsedMs, getTotalElapsedMs } from "./step-elapsed"; | ||||||
| import { formatElapsedTime } from "./format-elapsed-time"; | ||||||
| import { writeRunResult } from "./write-run-result"; | ||||||
|
|
||||||
| class ExecutionTimeoutError extends Schema.ErrorClass<ExecutionTimeoutError>( | ||||||
| "ExecutionTimeoutError", | ||||||
|
|
@@ -34,6 +35,7 @@ interface HeadlessRunOptions { | |||||
| ci: boolean; | ||||||
| timeoutMs: Option.Option<number>; | ||||||
| output: "text" | "json"; | ||||||
| scopeTier: ScopeTier; | ||||||
| baseUrl?: string; | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -132,6 +134,7 @@ export const runHeadless = (options: HeadlessRunOptions) => | |||||
| instruction: options.instruction, | ||||||
| isHeadless: !options.headed, | ||||||
| cookieBrowserKeys: [], | ||||||
| scopeTier: options.scopeTier, | ||||||
| baseUrl: options.baseUrl, | ||||||
| }) | ||||||
| .pipe( | ||||||
|
|
@@ -366,39 +369,47 @@ export const runHeadless = (options: HeadlessRunOptions) => | |||||
| ); | ||||||
| } | ||||||
|
|
||||||
| if (isJsonOutput) { | ||||||
| const stepResults = report.steps.map((step) => { | ||||||
| const entry = statuses.get(step.id); | ||||||
| const stepStatus = entry?.status ?? ("not-run" as const); | ||||||
| const elapsed = getStepElapsedMs(step); | ||||||
| return new CiStepResult({ | ||||||
| title: step.title, | ||||||
| status: stepStatus, | ||||||
| ...(elapsed !== undefined ? { duration_ms: elapsed } : {}), | ||||||
| ...(stepStatus === "failed" && entry?.summary ? { error: entry.summary } : {}), | ||||||
| }); | ||||||
| const stepResults = report.steps.map((step) => { | ||||||
| const entry = statuses.get(step.id); | ||||||
| const stepStatus = entry?.status ?? ("not-run" as const); | ||||||
| const elapsed = getStepElapsedMs(step); | ||||||
| return new CiStepResult({ | ||||||
| title: step.title, | ||||||
| status: stepStatus, | ||||||
| ...(elapsed !== undefined ? { duration_ms: elapsed } : {}), | ||||||
| ...(stepStatus === "failed" && entry?.summary ? { error: entry.summary } : {}), | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| const summaryParts = [`${passedCount} passed`, `${failedCount} failed`]; | ||||||
| if (skippedCount > 0) summaryParts.push(`${skippedCount} skipped`); | ||||||
| const summaryText = `${summaryParts.join(", ")} out of ${report.steps.length} step${report.steps.length === 1 ? "" : "s"}`; | ||||||
|
|
||||||
| const resultOutput = new CiResultOutput({ | ||||||
| version: VERSION, | ||||||
| status: report.status, | ||||||
| title: report.title, | ||||||
| duration_ms: totalDurationMs, | ||||||
| steps: stepResults, | ||||||
| artifacts: { | ||||||
| ...(effectiveVideoPath ? { video: effectiveVideoPath } : {}), | ||||||
| ...(artifacts.replayPath ? { replay: artifacts.replayPath } : {}), | ||||||
| ...(artifacts.screenshotPaths.length > 0 | ||||||
| ? { screenshots: [...artifacts.screenshotPaths] } | ||||||
| : {}), | ||||||
| }, | ||||||
| summary: summaryText, | ||||||
| }); | ||||||
| const summaryParts = [`${passedCount} passed`, `${failedCount} failed`]; | ||||||
| if (skippedCount > 0) summaryParts.push(`${skippedCount} skipped`); | ||||||
| const summaryText = `${summaryParts.join(", ")} out of ${report.steps.length} step${report.steps.length === 1 ? "" : "s"}`; | ||||||
|
|
||||||
| const resultOutput = new CiResultOutput({ | ||||||
| version: VERSION, | ||||||
| status: report.status, | ||||||
| title: report.title, | ||||||
| duration_ms: totalDurationMs, | ||||||
| steps: stepResults, | ||||||
| artifacts: { | ||||||
| ...(effectiveVideoPath ? { video: effectiveVideoPath } : {}), | ||||||
| ...(artifacts.replayPath ? { replay: artifacts.replayPath } : {}), | ||||||
| ...(artifacts.screenshotPaths.length > 0 | ||||||
| ? { screenshots: [...artifacts.screenshotPaths] } | ||||||
| : {}), | ||||||
| }, | ||||||
| summary: summaryText, | ||||||
| }); | ||||||
|
|
||||||
| const runResultPath = yield* writeRunResult( | ||||||
| finalExecuted.id ?? crypto.randomUUID(), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Use a truthy fallback for Prompt for AI agents
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| resultOutput, | ||||||
| ); | ||||||
| if (!isJsonOutput) { | ||||||
| process.stderr.write(`Run result: ${runResultPath}\n`); | ||||||
| } | ||||||
|
|
||||||
| if (isJsonOutput) { | ||||||
| const jsonString = JSON.stringify( | ||||||
| Schema.encodeSync(CiResultOutput)(resultOutput), | ||||||
| undefined, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import * as path from "node:path"; | ||
| import { Effect, Option, Schema } from "effect"; | ||
| import { FileSystem } from "effect/FileSystem"; | ||
| import { CiResultOutput } from "@expect/shared/models"; | ||
|
|
||
| // Persists structured run results to .expect/runs/{planId}.json so outer | ||
| // agents (Cursor, Claude Code, Codex) can read a single file instead of | ||
| // polling terminal output. Each run gets a unique planId (UUID), enabling | ||
| // parallel agent sessions without file conflicts. | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| const EXPECT_STATE_DIR = ".expect"; | ||
| const EXPECT_RUNS_DIR = "runs"; | ||
| const EXPECT_RUNS_MAX_KEPT = 20; | ||
|
|
||
| export const writeRunResult = Effect.fn("writeRunResult")(function* ( | ||
| planId: string, | ||
| resultOutput: CiResultOutput, | ||
| ) { | ||
| const fileSystem = yield* FileSystem; | ||
| const runsDir = path.join(process.cwd(), EXPECT_STATE_DIR, EXPECT_RUNS_DIR); | ||
|
|
||
| yield* fileSystem.makeDirectory(runsDir, { recursive: true }); | ||
|
|
||
| const filePath = path.join(runsDir, `${planId}.json`); | ||
| const jsonString = JSON.stringify(Schema.encodeSync(CiResultOutput)(resultOutput), undefined, 2); | ||
| yield* fileSystem.writeFileString(filePath, jsonString + "\n"); | ||
|
|
||
| yield* pruneOldRuns(runsDir); | ||
|
|
||
| return filePath; | ||
| }); | ||
|
|
||
| const pruneOldRuns = Effect.fn("pruneOldRuns")(function* (runsDir: string) { | ||
| const fileSystem = yield* FileSystem; | ||
|
|
||
| const entries = yield* fileSystem.readDirectory(runsDir); | ||
| const jsonFiles = entries.filter((file) => file.endsWith(".json")); | ||
|
|
||
| if (jsonFiles.length <= EXPECT_RUNS_MAX_KEPT) return; | ||
|
|
||
| const withStats = yield* Effect.forEach( | ||
| jsonFiles, | ||
| (file) => | ||
| Effect.gen(function* () { | ||
| const filePath = path.join(runsDir, file); | ||
| const stat = yield* fileSystem.stat(filePath); | ||
| const mtime = Option.getOrElse(stat.mtime, () => new Date(0)); | ||
| return { filePath, mtime: mtime.getTime() }; | ||
| }), | ||
| { concurrency: "unbounded" }, | ||
| ); | ||
|
|
||
| withStats.sort((left, right) => right.mtime - left.mtime); | ||
|
|
||
| yield* Effect.forEach( | ||
| withStats.slice(EXPECT_RUNS_MAX_KEPT), | ||
| (entry) => fileSystem.remove(entry.filePath), | ||
| { concurrency: "unbounded" }, | ||
| ); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The summary construction in execution-atom.ts is missing the skipped count, resulting in inconsistent output compared to run-test.ts