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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
.coverage
.probitas/
result
dist/
src/cli/_embedded_templates.ts
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ probitas run
# Run scenarios with specific tag
probitas run -s tag:api

# Re-run only failed scenarios from previous run
probitas run --failed

# Run with JSON output
probitas run --reporter json

Expand Down
7 changes: 7 additions & 0 deletions assets/usage-run.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Options:
-S, --sequential Run scenarios sequentially (alias for --max-concurrency=1)
--max-failures <n> Stop after N failures
-f, --fail-fast Stop on first failure (alias for --max-failures=1)
-F, --failed Run only scenarios that failed in the previous run
Reads from .probitas/last-run.json
--config <path> Path to config file (deno.json/deno.jsonc)
--env <file> Load environment variables from file (default: .env)
--no-env Skip loading .env file
Expand Down Expand Up @@ -93,6 +95,11 @@ Examples:
probitas run -f Stop on first failure (alias)
probitas run --reload Reload dependencies before running

# Re-run failed scenarios
probitas run --failed Run only scenarios that failed previously
probitas run -F Run only scenarios that failed previously (short)
probitas run -F -s tag:api Run failed scenarios AND with @api tag

# Output control
probitas run -v Verbose output
probitas run --verbose Verbose output (same as -v)
Expand Down
3 changes: 3 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@
"@std/testing/bdd": "jsr:@std/testing@^1.0.16/bdd",
"@std/testing/mock": "jsr:@std/testing@^1.0.16/mock",
"@std/testing/time": "jsr:@std/testing@^1.0.16/time",
"@std/text": "jsr:@std/text@^1.0.17",
"@std/text/closest-string": "jsr:@std/text@^1.0.17/closest-string",
"@std/text/levenshtein-distance": "jsr:@std/text@^1.0.17/levenshtein-distance",
"jsr:@probitas/probitas@^0": "./src/mod.ts"
}
}
12 changes: 12 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 24 additions & 2 deletions src/cli/_templates/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ runSubprocess<RunCommandInput>(async (ipc, input) => {
timeout,
stepOptions,
logLevel,
failedFilter,
} = input;

// Create abort controller for this run
Expand All @@ -75,8 +76,8 @@ runSubprocess<RunCommandInput>(async (ipc, input) => {
await configureLogging(logLevel);

try {
// Load scenarios from files
const scenarios = applySelectors(
// Load scenarios from files and apply selectors
let scenarios = applySelectors(
await loadScenarios(filePaths, {
onImportError: (file, err) => {
const m = err instanceof Error ? err.message : String(err);
Expand All @@ -86,6 +87,26 @@ runSubprocess<RunCommandInput>(async (ipc, input) => {
selectors,
);

// Apply failed filter if provided (for --failed flag)
if (failedFilter && failedFilter.length > 0) {
const failedSet = new Set(
failedFilter.map((f) => `${f.name}|${f.file}`),
);
scenarios = scenarios.filter((s) => {
const scenarioFile = s.origin?.path ?? "unknown";
// Check both absolute path and relative path matching
return failedSet.has(`${s.name}|${scenarioFile}`) ||
failedFilter.some((f) =>
f.name === s.name && scenarioFile.endsWith(f.file)
);
Comment on lines +92 to +101
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failed filter matching logic has a bug. The Set on line 92-94 uses ${f.name}|${f.file} where f.file is a relative path from the state file. However, on line 98, it checks against ${s.name}|${scenarioFile} where scenarioFile is an absolute path from s.origin?.path. Since these formats don't match (relative vs absolute), the Set lookup on line 98 will always fail.

Additionally, the endsWith fallback check on line 100 doesn't respect path separators, which could cause false positives. For example, if the stored path is "api/test.probitas.ts", both "/project/api/test.probitas.ts" and "/project/myapi/test.probitas.ts" would incorrectly match.

To fix this, you should either:

  1. Convert scenarioFile to a relative path (relative to cwd) before comparison
  2. Or normalize both paths to absolute paths before building the Set

The safer approach is option 1, converting scenarioFile to relative path for comparison.

Copilot uses AI. Check for mistakes.
});

logger.debug("Applied failed filter", {
filterCount: failedFilter.length,
matchedCount: scenarios.length,
});
}

// Check if aborted during loading phase
if (globalAbortController.signal.aborted) {
throw new Error("Aborted during scenario loading");
Expand All @@ -96,6 +117,7 @@ runSubprocess<RunCommandInput>(async (ipc, input) => {
logger.info("No scenarios found after applying selectors", {
filePaths,
selectors,
failedFilter: failedFilter?.length ?? 0,
});
}

Expand Down
12 changes: 12 additions & 0 deletions src/cli/_templates/run_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export type RunInput =
| RunCommandInput
| RunAbortInput;

/**
* Failed scenario identifier for filtering
*/
export interface FailedScenarioFilter {
/** Scenario name */
readonly name: string;
/** File path (relative or absolute) */
readonly file: string;
}

/**
* Run scenarios command
*/
Expand All @@ -53,6 +63,8 @@ export interface RunCommandInput {
readonly stepOptions?: StepOptions;
/** Log level for subprocess logging */
readonly logLevel: LogLevel;
/** Filter to only run these failed scenarios (name + file pairs) */
readonly failedFilter?: readonly FailedScenarioFilter[];
}

/**
Expand Down
82 changes: 58 additions & 24 deletions src/cli/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { fromErrorObject, isErrorObject } from "@core/errorutil/error-object";
import { unreachable } from "@core/errorutil/unreachable";
import { EXIT_CODE } from "../constants.ts";
import { findProbitasConfigFile, loadConfig } from "../config.ts";
import {
createUnknownArgHandler,
extractKnownOptions,
formatUnknownArgError,
} from "../unknown_args.ts";
import { createDiscoveryProgress, writeStatus } from "../progress.ts";
import {
configureLogging,
Expand All @@ -29,6 +34,36 @@ import {

const logger = getLogger(["probitas", "cli", "list"]);

/**
* parseArgs configuration for the list command
*/
const PARSE_ARGS_CONFIG = {
string: ["config", "include", "exclude", "selector", "env"],
boolean: [
"help",
"json",
"no-env",
"reload",
"quiet",
"verbose",
"debug",
],
collect: ["include", "exclude", "selector"],
alias: {
h: "help",
s: "selector",
r: "reload",
v: "verbose",
q: "quiet",
d: "debug",
},
default: {
include: undefined,
exclude: undefined,
selector: undefined,
},
} as const;

/**
* Execute the list command
*
Expand All @@ -46,34 +81,33 @@ export async function listCommand(
// Extract deno options first (before parseArgs)
const { denoArgs, remainingArgs } = extractDenoOptions(args);

// Setup unknown argument handler
const knownOptions = extractKnownOptions(PARSE_ARGS_CONFIG);
const { handler: unknownHandler, result: unknownResult } =
createUnknownArgHandler({
knownOptions,
commandName: "list",
});

// Parse command-line arguments
const parsed = parseArgs(remainingArgs, {
string: ["config", "include", "exclude", "selector", "env"],
boolean: [
"help",
"json",
"no-env",
"reload",
"quiet",
"verbose",
"debug",
],
collect: ["include", "exclude", "selector"],
alias: {
h: "help",
s: "selector",
r: "reload",
v: "verbose",
q: "quiet",
d: "debug",
},
default: {
include: undefined,
exclude: undefined,
selector: undefined,
},
...PARSE_ARGS_CONFIG,
unknown: unknownHandler,
});

// Check for unknown arguments before showing help
if (unknownResult.hasErrors) {
for (const unknown of unknownResult.unknownArgs) {
console.error(
formatUnknownArgError(unknown, {
knownOptions,
commandName: "list",
}),
);
}
return EXIT_CODE.USAGE_ERROR;
}

// Show help if requested
if (parsed.help) {
try {
Expand Down
Loading
Loading