Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[submodule ".repos/effect"]
path = .repos/effect
url = git@github.com:Effect-TS/effect-smol.git
url = https://github.com/Effect-TS/effect-smol.git
8 changes: 7 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.format.oxc": "always", // run formatter first
"source.fixAll.oxc": "always", // run lint fixes after
},
}
18 changes: 16 additions & 2 deletions apps/cli/src/commands/add-github-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const generateWorkflow = (packageManager: PackageManager, devCommand: string, de

const setupSteps = buildSetupSteps(packageManager);

return `name: Expect Tests
return `name: Expect Browser Tests

on:
pull_request:
Expand All @@ -57,24 +57,33 @@ jobs:
pull-requests: write
env:
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
# Expect uses this local app URL as the browser test target in CI.
EXPECT_BASE_URL: "${devUrl}"
steps:
- uses: actions/checkout@v4
${setupSteps}
- name: Install dependencies
run: ${install}

# Expect runs against your dev server by default, not a production build or deployed preview.
# To test a preview URL instead, set EXPECT_BASE_URL to that URL. You can use
# https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idneeds
# to pass preview URLs from other jobs.
- name: Start dev server
run: ${devCommand} &

# Wait until the local app is reachable before handing control to the browser agent.
- name: Wait for dev server
run: npx wait-on ${devUrl} --timeout 60000


- name: Run expect
env:
# Expect uses the GitHub token to comment on the pull request.
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
run: ${dlx} expect-cli@latest --ci

# Upload browser recordings and traces from .expect so failures are debuggable after the run.
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
Expand Down Expand Up @@ -175,7 +184,12 @@ export const runAddGithubAction = async (options: AddGithubActionOptions = {}) =
logger.success("Created .github/workflows/expect.yml");
logger.break();
logger.log(` Add ${highlighter.info("ANTHROPIC_API_KEY")} to your repository secrets:`);
logger.break();
logger.log(` You can use the ${highlighter.info("gh")} CLI to add repository secrets:`);
logger.log(
` ${highlighter.dim("claude setup-token")} ${highlighter.dim("# use Claude Code to generate a token, then paste it into ANTHROPIC_API_KEY")}`,
);
logger.log(
` ${highlighter.dim("Settings → Secrets and variables → Actions → New repository secret")}`,
` ${highlighter.dim("gh secret set ANTHROPIC_API_KEY")} ${highlighter.dim("# for an Anthropic API key or a token from claude setup-token")}`,
);
};
252 changes: 124 additions & 128 deletions apps/cli/src/data/execution-atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
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";

const LIVE_VIEW_PORT_MIN = 50000;
const LIVE_VIEW_PORT_RANGE = 10000;
Expand All @@ -35,6 +36,30 @@ export interface ExecutionResult {

export const screenshotPathsAtom = Atom.make<readonly string[]>([]);

const syncReplayProxy = Effect.fn("syncReplayProxy")(function* (
replayUrl: string | undefined,
liveViewUrl: string,
replaySessionPath: string | undefined,
executed: ExecutedTestPlan,
) {
if (!replayUrl) return;

const proxyBase = replayUrl.split("/replay")[0];
const replayEvents = yield* loadReplayEvents({ liveViewUrl, replaySessionPath });

if (replayEvents && replayEvents.length > 0) {
yield* Effect.tryPromise(() =>
fetch(`${proxyBase}/latest.json`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(replayEvents),
}),
).pipe(Effect.catchCause(() => Effect.void));
}

yield* pushStepState(proxyBase, toViewerRunState(executed));
});

const execute = Effect.fnUntraced(
function* (input: ExecuteInput, _ctx: Atom.FnContext) {
const reporter = yield* Reporter;
Expand Down Expand Up @@ -104,22 +129,7 @@ const execute = Effect.fnUntraced(

const artifacts = extractCloseArtifacts(finalExecuted.events);

if (replayUrl) {
const proxyBase = replayUrl.split("/replay")[0];
yield* Effect.tryPromise(() =>
fetch(`${liveViewUrl}/latest.json`).then(async (response) => {
if (!response.ok) return;
const allEvents = await response.json();
await fetch(`${proxyBase}/latest.json`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(allEvents),
});
}),
).pipe(Effect.catchCause(() => Effect.void));

yield* pushStepState(proxyBase, toViewerRunState(finalExecuted));
}
yield* syncReplayProxy(replayUrl, liveViewUrl, artifacts.replaySessionPath, finalExecuted);

const report = yield* reporter.report(finalExecuted);

Expand Down Expand Up @@ -170,123 +180,109 @@ export const executeFn = cliAtomRuntime.fn<ExecuteInput>()((input, ctx) =>
),
);

export const executeAtomFn = cliAtomRuntime.fn(
Effect.fnUntraced(
function* (input: ExecuteInput, _ctx: Atom.FnContext) {
const reporter = yield* Reporter;
const executor = yield* Executor;
const analytics = yield* Analytics;
const git = yield* Git;

const runStartedAt = Date.now();

const liveViewPort = pickRandomPort();
const liveViewUrl = `http://localhost:${liveViewPort}`;
const executeAtom = Effect.fnUntraced(
function* (input: ExecuteInput, _ctx: Atom.FnContext) {
const reporter = yield* Reporter;
const executor = yield* Executor;
const analytics = yield* Analytics;
const git = yield* Git;

let replayUrl: string | undefined;
const runStartedAt = Date.now();

if (input.replayHost) {
const proxyHandle = yield* startReplayProxy({
replayHost: input.replayHost,
liveViewUrl,
});
replayUrl = `${proxyHandle.url}/replay`;
const liveViewPort = pickRandomPort();
const liveViewUrl = `http://localhost:${liveViewPort}`;

yield* Effect.logInfo("Replay viewer available", { replayUrl });
yield* Effect.sync(() => input.onReplayUrl?.(`${replayUrl}?live=true`));
}
let replayUrl: string | undefined;

const executeOptions: ExecuteOptions = {
...input.options,
if (input.replayHost) {
const proxyHandle = yield* startReplayProxy({
replayHost: input.replayHost,
liveViewUrl,
onConfigOptions: input.onConfigOptions,
};

yield* analytics.capture("run:started", { plan_id: "direct" });

const finalExecuted = yield* executor.execute(executeOptions).pipe(
Stream.tap((executed) =>
Effect.gen(function* () {
input.onUpdate(executed);
yield* pushStepState(liveViewUrl, toViewerRunState(executed));
}),
),
Stream.runLast,
Effect.map((option) =>
(option._tag === "Some"
? option.value
: new ExecutedTestPlan({
...input.options,
id: "" as never,
changesFor: input.options.changesFor,
currentBranch: "",
diffPreview: "",
fileStats: [],
instruction: input.options.instruction,
baseUrl: undefined as never,
isHeadless: input.options.isHeadless,
cookieBrowserKeys: input.options.cookieBrowserKeys,
testCoverage: Option.none(),
title: input.options.instruction,
rationale: "Direct execution",
steps: [],
events: [],
})
)
.finalizeTextBlock()
.synthesizeRunFinished(),
),
);

const artifacts = extractCloseArtifacts(finalExecuted.events);

if (replayUrl) {
const proxyBase = replayUrl.split("/replay")[0];
yield* Effect.tryPromise(() =>
fetch(`${liveViewUrl}/latest.json`).then(async (response) => {
if (!response.ok) return;
const allEvents = await response.json();
await fetch(`${proxyBase}/latest.json`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(allEvents),
});
}),
).pipe(Effect.catchCause(() => Effect.void));

yield* pushStepState(proxyBase, toViewerRunState(finalExecuted));
}

const report = yield* reporter.report(finalExecuted);

const passedCount = report.steps.filter(
(step) => report.stepStatuses.get(step.id)?.status === "passed",
).length;
const failedCount = report.steps.filter(
(step) => report.stepStatuses.get(step.id)?.status === "failed",
).length;

yield* analytics.capture("run:completed", {
plan_id: finalExecuted.id ?? "direct",
passed: passedCount,
failed: failedCount,
step_count: finalExecuted.steps.length,
file_count: 0,
duration_ms: Date.now() - runStartedAt,
});
replayUrl = `${proxyHandle.url}/replay`;

if (report.status === "passed") {
yield* git.saveTestedFingerprint();
}

return {
executedPlan: finalExecuted,
report,
replayUrl: replayUrl ?? artifacts.localReplayUrl,
localReplayUrl: artifacts.localReplayUrl,
videoUrl: artifacts.videoUrl,
} satisfies ExecutionResult;
},
Effect.annotateLogs({ fn: "executeAtomFn" }),
),
yield* Effect.logInfo("Replay viewer available", { replayUrl });
yield* Effect.sync(() => input.onReplayUrl?.(`${replayUrl}?live=true`));
}

const executeOptions: ExecuteOptions = {
...input.options,
liveViewUrl,
onConfigOptions: input.onConfigOptions,
};

yield* analytics.capture("run:started", { plan_id: "direct" });

const finalExecuted = yield* executor.execute(executeOptions).pipe(
Stream.tap((executed) =>
Effect.gen(function* () {
input.onUpdate(executed);
yield* pushStepState(liveViewUrl, toViewerRunState(executed));
}),
),
Stream.runLast,
Effect.map((option) =>
(option._tag === "Some"
? option.value
: new ExecutedTestPlan({
...input.options,
id: "" as never,
changesFor: input.options.changesFor,
currentBranch: "",
diffPreview: "",
fileStats: [],
instruction: input.options.instruction,
baseUrl: undefined as never,
isHeadless: input.options.isHeadless,
cookieBrowserKeys: input.options.cookieBrowserKeys,
testCoverage: Option.none(),
title: input.options.instruction,
rationale: "Direct execution",
steps: [],
events: [],
})
)
.finalizeTextBlock()
.synthesizeRunFinished(),
),
);

const artifacts = extractCloseArtifacts(finalExecuted.events);

yield* syncReplayProxy(replayUrl, liveViewUrl, artifacts.replaySessionPath, finalExecuted);

const report = yield* reporter.report(finalExecuted);

const passedCount = report.steps.filter(
(step) => report.stepStatuses.get(step.id)?.status === "passed",
).length;
const failedCount = report.steps.filter(
(step) => report.stepStatuses.get(step.id)?.status === "failed",
).length;

yield* analytics.capture("run:completed", {
plan_id: finalExecuted.id ?? "direct",
passed: passedCount,
failed: failedCount,
step_count: finalExecuted.steps.length,
file_count: 0,
duration_ms: Date.now() - runStartedAt,
});

if (report.status === "passed") {
yield* git.saveTestedFingerprint();
}

return {
executedPlan: finalExecuted,
report,
replayUrl: replayUrl ?? artifacts.localReplayUrl,
localReplayUrl: artifacts.localReplayUrl,
videoUrl: artifacts.videoUrl,
} satisfies ExecutionResult;
},
Effect.annotateLogs({ fn: "executeAtomFn" }),
Effect.provide(NodeServices.layer),
);

export const executeAtomFn = cliAtomRuntime.fn(executeAtom);
Loading
Loading