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
5 changes: 5 additions & 0 deletions .changeset/gitignored-default-output-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": patch
---

Change the default `flows run` output directory from `qawolf-output` to `.qawolf/output`, so run artifacts (videos, traces, HAR) land under the `.qawolf/` directory that `qawolf init` gitignores. Pass `--output-dir` to override.
5 changes: 5 additions & 0 deletions .changeset/har-trace-artifacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": patch
---

Save HAR and trace artifacts reliably from `flows run`. HAR (and video) could be silently dropped because contexts and browsers were closed concurrently, racing Playwright's artifact flush; contexts now close first. The `--trace` flag is now wired end-to-end and writes a Playwright trace to `<output-dir>/trace/<flow>.zip`, honoring `on`, `off`, and `retain-on-failure`.
5 changes: 5 additions & 0 deletions .changeset/timeout-web-flow-expect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": patch
---

Wire `flows run --timeout` into the web flow expect timeout. Previously the flag only set Playwright's per-action timeout, so assertion failures still waited the hardcoded 30s default; it now applies to actions and assertions alike.
5 changes: 3 additions & 2 deletions src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ Options:
0)
--bail Stop the run after the first failure (default: false)
--workers <n> Parallel worker count for web flows (default: 1)
--timeout <ms> Per-flow timeout in milliseconds (default: 30000)
--timeout <ms> Default timeout for actions and assertions, in
milliseconds (default: 30000)
--video <mode> Record video: on | off | retain-on-failure (default:
"off")
--trace <mode> Record Playwright trace: on | off | retain-on-failure
Expand All @@ -147,7 +148,7 @@ Options:
--har-content <mode> HAR response bodies: omit | full (full uses more memory)
(default: "omit")
--output-dir <path> Directory for run artifacts (videos, traces, HAR)
(default: "qawolf-output")
(default: ".qawolf/output")
--junit [path] Write a JUnit XML report (default:
<output-dir>/junit-report.xml)
--headed Show the browser window instead of headless (default:
Expand Down
5 changes: 3 additions & 2 deletions src/commands/flows/run.register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
TraceMode,
VideoMode,
} from "~/core/types.js";
import { defaultOutputDir } from "~/core/paths.js";
import { parseEnum, parseInteger } from "~/domains/runner/runFlagParsers.js";
import type { FlowsRunFlags } from "~/domains/runner/runInternals.js";

Expand Down Expand Up @@ -56,7 +57,7 @@ export function registerFlowsRunCommand(
)
.option(
"--timeout <ms>",
"Per-flow timeout in milliseconds",
"Default timeout for actions and assertions, in milliseconds",
parseInteger("--timeout", { min: 0 }),
30_000,
)
Expand Down Expand Up @@ -87,7 +88,7 @@ export function registerFlowsRunCommand(
.option(
"--output-dir <path>",
"Directory for run artifacts (videos, traces, HAR)",
"qawolf-output",
defaultOutputDir,
)
.option(
"--junit [path]",
Expand Down
4 changes: 4 additions & 0 deletions src/core/messages/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const runnerMessages = {
`retries must be a non-negative integer, got ${String(retries)}`,
manifestStampReadFailed: (file: string, message: string) =>
`failed to read manifest stamp for ${file}: ${message}`,
harCleanupFailed: (file: string, message: string) =>
`failed to delete HAR file ${file} (retain-on-failure); remove it manually: ${message}`,
traceCleanupFailed: (file: string, message: string) =>
`failed to delete trace file ${file} (retain-on-failure); remove it manually: ${message}`,
notSupportedInCli: (name: string) =>
`${name} is not supported in the CLI runner`,
notAvailableLocally: (name: string) =>
Expand Down
13 changes: 13 additions & 0 deletions src/core/paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "bun:test";

import { defaultOutputDir, qawolfDir } from "./paths.js";

describe("defaultOutputDir", () => {
it("is nested inside the qawolf directory that init gitignores", () => {
expect(defaultOutputDir.startsWith(`${qawolfDir}/`)).toBe(true);
});

it("is a relative path so artifacts land in the project, not an absolute location", () => {
expect(defaultOutputDir.startsWith("/")).toBe(false);
});
});
13 changes: 13 additions & 0 deletions src/core/paths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import envPaths from "env-paths";

/**
* Directory `qawolf init` writes a catch-all `.gitignore` into, so anything
* nested under it is excluded from version control by default.
*/
export const qawolfDir = ".qawolf";

/**
* Default directory for `flows run` artifacts (videos, traces, HAR). Nested
* under {@link qawolfDir} so the `.gitignore` written by `qawolf init` keeps
* run artifacts out of version control.
*/
export const defaultOutputDir = `${qawolfDir}/output`;

let _paths: ReturnType<typeof envPaths> | undefined;

export function getConfigDir(): string {
Expand Down
2 changes: 1 addition & 1 deletion src/domains/config/loadConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe("loadConfig", () => {
it("returns defaults when qawolf.config.ts is missing", async () => {
const config = await loadConfig(missingConfig());
expect(config).toEqual({
outputDir: ".qawolf",
outputDir: ".qawolf/output",
timeout: 60_000,
retries: 0,
bail: false,
Expand Down
4 changes: 3 additions & 1 deletion src/domains/config/schema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { z } from "zod";

import { defaultOutputDir } from "~/core/paths.js";

const artifactModeSchema = z.enum(["on", "off", "retain-on-failure"]);

export const qawolfConfigSchema = z.strictObject({
outputDir: z.string().default(".qawolf"),
outputDir: z.string().default(defaultOutputDir),
timeout: z.number().int().positive().default(60_000),
retries: z.number().int().min(0).default(0),
bail: z.boolean().default(false),
Expand Down
4 changes: 3 additions & 1 deletion src/domains/init/templates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defaultOutputDir } from "~/core/paths.js";

export const qawolfConfigTs = `export default {
outputDir: ".qawolf",
outputDir: "${defaultOutputDir}",
timeout: 60_000,
retries: 0,
video: "retain-on-failure",
Expand Down
1 change: 1 addition & 0 deletions src/domains/runner/dispatchViaSubprocess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const webOptions: RunWebFlowOptions = {
headed: false,
slowMo: 0,
video: "off",
trace: "off",
timeout: 30_000,
har: "off",
harContent: "omit",
Expand Down
1 change: 1 addition & 0 deletions src/domains/runner/executeWorkerFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function makeInput(): WorkerInput {
headed: false,
slowMo: 0,
video: "off",
trace: "off",
timeout: 30_000,
har: "off",
harContent: "omit",
Expand Down
49 changes: 41 additions & 8 deletions src/domains/runner/initFlowRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,47 @@ afterEach(() => {

const thisDir = import.meta.dirname;

/** Reads back the expect timeout the runner configured on @qawolf/flows. */
async function readConfiguredExpectTimeout(): Promise<number> {
const idxUrl = import.meta.resolve("@qawolf/flows");
const attrsUrl = new URL("./web/expect/attributes.js", idxUrl).href;
const { getWebExpectAttributes } = (await import(attrsUrl)) as {
getWebExpectAttributes: () => { defaultExpectTimeoutMs: number };
};
return getWebExpectAttributes().defaultExpectTimeoutMs;
}

describe("initFlowRuntime", () => {
it("resolves using the CLI's own @qawolf/flows", async () => {
await initFlowRuntime(path.join(thisDir, "fake.flow.ts"));
await initFlowRuntime(path.join(thisDir, "fake.flow.ts"), {
timeout: 30_000,
});
});

it("configures the @qawolf/flows expect timeout from the passed timeout", async () => {
await initFlowRuntime(path.join(thisDir, "fake.flow.ts"), {
timeout: 5_000,
});
expect(await readConfiguredExpectTimeout()).toBe(5_000);
});

it("returns the same promise for repeated calls from the same directory", () => {
const p1 = initFlowRuntime(path.join(thisDir, "a.flow.ts"));
const p2 = initFlowRuntime(path.join(thisDir, "b.flow.ts"));
const p1 = initFlowRuntime(path.join(thisDir, "a.flow.ts"), {
timeout: 30_000,
});
const p2 = initFlowRuntime(path.join(thisDir, "b.flow.ts"), {
timeout: 30_000,
});
expect(p1).toBe(p2);
});

it("returns a different promise for a different starting directory", () => {
const p1 = initFlowRuntime(path.join(thisDir, "a.flow.ts"));
const p2 = initFlowRuntime(path.join(thisDir, "sub", "b.flow.ts"));
const p1 = initFlowRuntime(path.join(thisDir, "a.flow.ts"), {
timeout: 30_000,
});
const p2 = initFlowRuntime(path.join(thisDir, "sub", "b.flow.ts"), {
timeout: 30_000,
});
expect(p1).not.toBe(p2);
// settle both so they don't leak into subsequent tests
return Promise.allSettled([p1, p2]);
Expand All @@ -34,7 +61,9 @@ describe("initFlowRuntime", () => {
try {
let caught: unknown;
try {
await initFlowRuntime(path.join(tmp, "my.flow.ts"));
await initFlowRuntime(path.join(tmp, "my.flow.ts"), {
timeout: 30_000,
});
} catch (e) {
caught = e;
}
Expand All @@ -60,7 +89,9 @@ describe("initFlowRuntime", () => {
);
let caught: unknown;
try {
await initFlowRuntime(path.join(tmp, "my.flow.ts"));
await initFlowRuntime(path.join(tmp, "my.flow.ts"), {
timeout: 30_000,
});
} catch (e) {
caught = e;
}
Expand All @@ -82,7 +113,9 @@ describe("initFlowRuntime", () => {
await mkdir(path.join(pkgDir, "package.json"));
let caught: unknown;
try {
await initFlowRuntime(path.join(tmp, "my.flow.ts"));
await initFlowRuntime(path.join(tmp, "my.flow.ts"), {
timeout: 30_000,
});
} catch (e) {
caught = e;
}
Expand Down
27 changes: 23 additions & 4 deletions src/domains/runner/initFlowRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@ import { isNoEntError } from "~/core/errors.js";

type ConfigureFlowRuntime = (opts: {
target: unknown;
webExpectAttributes?: unknown;
webExpectAttributes?: { defaultExpectTimeoutMs: number };
}) => Promise<void>;

export type InitFlowRuntimeOptions = {
/**
* Default timeout (ms) for flow actions and assertions. Threaded into
* @qawolf/flows as `defaultExpectTimeoutMs` so the package's `expect`
* wrapper honors `--timeout`; without it the wrapper pins every assertion
* to its hardcoded 30s default. The matching Playwright action timeout is
* applied separately via `context.setDefaultTimeout` at launch.
*/
timeout: number;
};

async function findFlowsRunnerPath(flowPath: string, fs: Fs): Promise<string> {
let dir = path.dirname(flowPath);
while (true) {
Expand Down Expand Up @@ -47,7 +58,11 @@ async function findFlowsRunnerPath(flowPath: string, fs: Fs): Promise<string> {

const initCache = new Map<string, Promise<void>>();

async function doInit(flowPath: string, fs: Fs): Promise<void> {
async function doInit(
flowPath: string,
timeout: number,
fs: Fs,
): Promise<void> {
const runnerPath = await findFlowsRunnerPath(flowPath, fs);
const mod = (await import(pathToFileURL(runnerPath).href)) as {
configureFlowRuntime?: ConfigureFlowRuntime;
Expand All @@ -63,18 +78,22 @@ async function doInit(flowPath: string, fs: Fs): Promise<void> {
runnerName: "node20WithPlaywright",
meta: "legacy",
},
webExpectAttributes: { defaultExpectTimeoutMs: timeout },
});
}

export function initFlowRuntime(
flowPath: string,
options: InitFlowRuntimeOptions,
fs: Fs = makeDefaultFs(),
): Promise<void> {
const startDir = path.dirname(flowPath);
// Cache key is startDir, not fs — tests reusing the same startDir must call _resetInitCache() between runs.
// Cache key is startDir, not fs — tests reusing the same startDir must call
// _resetInitCache() between runs. Timeout is omitted deliberately: it is a
// single run-global flag, so every flow in a process shares one value.
let p = initCache.get(startDir);
if (!p) {
p = doInit(flowPath, fs);
p = doInit(flowPath, options.timeout, fs);
initCache.set(startDir, p);
}
return p;
Expand Down
3 changes: 3 additions & 0 deletions src/domains/runner/run.manifestStamp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe("dispatchFlow manifest stamping", () => {
headed: false,
slowMo: 0,
video: "off",
trace: "off",
timeout: defaultFlags().timeout,
},
androidOptions: {
Expand Down Expand Up @@ -104,6 +105,7 @@ describe("dispatchFlow manifest stamping", () => {
headed: false,
slowMo: 0,
video: "off",
trace: "off",
timeout: defaultFlags().timeout,
},
androidOptions: {
Expand Down Expand Up @@ -142,6 +144,7 @@ describe("dispatchFlow manifest stamping", () => {
headed: false,
slowMo: 0,
video: "off",
trace: "off",
timeout: defaultFlags().timeout,
},
androidOptions: {
Expand Down
5 changes: 5 additions & 0 deletions src/domains/runner/runHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ describe("buildRunOptions", () => {
const { webOptions } = buildRunOptions({ ...defaultFlags(), headed: true });
expect(webOptions.headed).toBe(true);
});

it("passes the trace mode through to webOptions", () => {
const { webOptions } = buildRunOptions({ ...defaultFlags(), trace: "on" });
expect(webOptions.trace).toBe("on");
});
});
1 change: 1 addition & 0 deletions src/domains/runner/runHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function buildRunOptions(flags: FlowsRunFlags): {
headed: flags.headed,
slowMo: 0,
video: flags.video,
trace: flags.trace,
timeout: flags.timeout,
har: flags.har,
harContent: flags.harContent,
Expand Down
1 change: 1 addition & 0 deletions src/domains/runner/runWebFlow.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const baseOptions: RunWebFlowOptions = {
headed: false,
slowMo: 0,
video: "off",
trace: "off",
timeout: 30_000,
};

Expand Down
29 changes: 29 additions & 0 deletions src/domains/runner/runWebFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,46 @@ import {
makeUniformDeps,
} from "./web/createWebLaunchContext.fixtures.js";
import { runWebFlow } from "./runWebFlow.js";
import { _resetInitCache } from "./initFlowRuntime.js";
import {
baseOptions,
fixturePath,
makeWebDeps,
} from "./runWebFlow.fixtures.js";

afterEach(() => {
// The expect-timeout readback test configures a process-global on
// @qawolf/flows; clear the init cache so it does not leak into later tests.
_resetInitCache();
mock.restore();
});

/** Reads back the expect timeout configured on @qawolf/flows. */
async function readConfiguredExpectTimeout(): Promise<number> {
const idxUrl = import.meta.resolve("@qawolf/flows");
const attrsUrl = new URL("./web/expect/attributes.js", idxUrl).href;
const { getWebExpectAttributes } = (await import(attrsUrl)) as {
getWebExpectAttributes: () => { defaultExpectTimeoutMs: number };
};
return getWebExpectAttributes().defaultExpectTimeoutMs;
}

describe("runWebFlow", () => {
it("should configure the @qawolf/flows expect timeout from options.timeout", async () => {
// initFlowRuntime memoizes per flow directory and configures a
// process-global on @qawolf/flows. Other runner tests share this
// directory, so clear the cache to force a fresh configure here.
_resetInitCache();

await runWebFlow({
deps: makeWebDeps(),
options: { ...baseOptions, timeout: 7_777 },
flowPath: fixturePath("pass"),
});

expect(await readConfiguredExpectTimeout()).toBe(7_777);
});

it("should return passed: true when the flow succeeds", async () => {
const result = await runWebFlow({
deps: makeWebDeps(),
Expand Down
Loading