Skip to content

Commit 7660d05

Browse files
committed
fix(test): address Codex round-7 findings
The push trigger's paths-ignore becomes a paths list with negations so baseline-only .md pushes still run CI. list-dir hides dot entries like plain ls (--all opts in) and the migrator TODOs ls flags. vp/vpr/vpx resolve through the case PATH first so VP_HOME/bin shims shadow the harness aliases. A failing step now stops the case by default (shell-like && semantics) with continue-on-failure as the opt-out; the migrator restores legacy semantics exactly by marking each command line's final step. Bare runtime-tool versions (Node 24.x, pnpm 10.x from vp create) redact by tool-name context while user semver stays assertable. Claude-Session: https://claude.ai/code/session_01NRgjMi2Vus3iJctudGEWPT
1 parent bfad895 commit 7660d05

10 files changed

Lines changed: 124 additions & 24 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ on:
1111
push:
1212
branches:
1313
- main
14-
paths-ignore:
15-
- '**/*.md'
14+
# `paths` with negations instead of paths-ignore: PTY snapshot baselines
15+
# are .md files (crates/vite_cli_snapshots/**/snapshots/*.md) and a
16+
# baseline-only push must still run CI. Later patterns win.
17+
paths:
18+
- '**'
19+
- '!**/*.md'
20+
- 'crates/vite_cli_snapshots/**'
1621

1722
concurrency:
1823
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}

crates/vite_cli_snapshots/src/bin/vpt/list_dir.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
1818
let mut dir: Option<&str> = None;
1919
let mut ext: Option<&str> = None;
2020
let mut recursive = false;
21+
let mut all = false;
2122
let mut i = 0;
2223
while i < args.len() {
2324
match args[i].as_str() {
@@ -26,12 +27,13 @@ pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
2627
ext = Some(args.get(i).ok_or("--ext requires a value")?.as_str());
2728
}
2829
"--recursive" => recursive = true,
30+
"--all" => all = true,
2931
other if dir.is_none() => dir = Some(other),
3032
other => return Err(format!("unexpected argument: {other}").into()),
3133
}
3234
i += 1;
3335
}
34-
let dir = dir.ok_or("Usage: vpt list-dir <dir> [--ext <suffix>] [--recursive]")?;
36+
let dir = dir.ok_or("Usage: vpt list-dir <dir> [--ext <suffix>] [--recursive] [--all]")?;
3537

3638
// Like `ls <file>`, a file target prints its own name; legacy fixtures
3739
// use that form as an existence assertion.
@@ -42,7 +44,7 @@ pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
4244
}
4345

4446
let mut names: Vec<String> = Vec::new();
45-
collect(path, ext, recursive, &mut names)?;
47+
collect(path, ext, recursive, all, &mut names)?;
4648
names.sort();
4749
for name in names {
4850
println!("{name}");
@@ -57,15 +59,22 @@ fn collect(
5759
dir: &std::path::Path,
5860
ext: Option<&str>,
5961
recursive: bool,
62+
all: bool,
6063
names: &mut Vec<String>,
6164
) -> Result<(), Box<dyn std::error::Error>> {
6265
for entry in std::fs::read_dir(dir)? {
6366
let entry = entry?;
67+
let name = entry.file_name().to_string_lossy().into_owned();
68+
// Plain ls hides dot entries; --all shows them. Keeping them hidden
69+
// by default also keeps snapshots free of package-manager internals
70+
// like .pnpm.
71+
if !all && name.starts_with('.') {
72+
continue;
73+
}
6474
if recursive && entry.file_type()?.is_dir() {
65-
collect(&entry.path(), ext, recursive, names)?;
75+
collect(&entry.path(), ext, recursive, all, &mut *names)?;
6676
continue;
6777
}
68-
let name = entry.file_name().to_string_lossy().into_owned();
6978
if let Some(suffix) = ext
7079
&& !name.ends_with(suffix)
7180
{

crates/vite_cli_snapshots/tests/cli_snapshots/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ A step is a bare argv array or a table:
9797
timeout = 120000, # ms, default 50s
9898
snapshot = false, # omit the screen while the step succeeds
9999
# (failures always keep their output)
100+
continue-on-failure = true, # a failing step normally stops the case
101+
# (shell-like &&); this lets later steps
102+
# inspect post-failure state
100103
tty = false, # piped stdio instead of a PTY (non-TTY tests)
101104
interactions = [ ... ] }
102105
```

crates/vite_cli_snapshots/tests/cli_snapshots/flavor.rs

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -285,23 +285,18 @@ impl FlavorRuntime {
285285
case_path: &std::ffi::OsStr,
286286
) -> Result<PathBuf, String> {
287287
match program {
288-
"vp" | "vpr" | "vpx" | "vpt" | "oxfmt" | "oxlint" => {
289-
let name = if cfg!(windows) {
290-
// Installed as either .exe or .cmd; try both.
291-
["exe", "cmd"]
292-
.iter()
293-
.map(|ext| self.bin_dir.join(format!("{program}.{ext}")))
294-
.find(|p| p.is_file())
295-
.ok_or_else(|| format!("`{program}` is not available in this flavor"))?
296-
} else {
297-
let p = self.bin_dir.join(program);
298-
if !p.is_file() && !p.is_symlink() {
299-
return Err(format!("`{program}` is not available in this flavor"));
300-
}
301-
p
302-
};
303-
Ok(name)
288+
"vp" | "vpr" | "vpx" | "oxfmt" | "oxlint" => {
289+
// Case PATH first: shims a case creates in $VP_HOME/bin must
290+
// shadow the harness-installed aliases. The flavor bin dir is
291+
// on that PATH too, so this is a pure precedence rule; the
292+
// direct bin-dir lookup below only remains as the fallback
293+
// for cases that override PATH entirely.
294+
if let Ok(found) = which::which_in(program, Some(case_path), PathBuf::from(".")) {
295+
return Ok(found);
296+
}
297+
self.bin_dir_tool(program)
304298
}
299+
"vpt" => self.bin_dir_tool(program),
305300
"node" | "git" | "npm" | "pnpm" | "yarn" | "bun" => {
306301
which::which_in(program, Some(case_path), PathBuf::from("."))
307302
.map_err(|e| format!("`{program}` not found on the case PATH: {e}"))
@@ -311,4 +306,22 @@ impl FlavorRuntime {
311306
)),
312307
}
313308
}
309+
310+
/// Looks a tool up directly in the flavor bin dir.
311+
fn bin_dir_tool(&self, program: &str) -> Result<PathBuf, String> {
312+
if cfg!(windows) {
313+
// Installed as either .exe or .cmd; try both.
314+
["exe", "cmd"]
315+
.iter()
316+
.map(|ext| self.bin_dir.join(format!("{program}.{ext}")))
317+
.find(|p| p.is_file())
318+
.ok_or_else(|| format!("`{program}` is not available in this flavor"))
319+
} else {
320+
let p = self.bin_dir.join(program);
321+
if !p.is_file() && !p.is_symlink() {
322+
return Err(format!("`{program}` is not available in this flavor"));
323+
}
324+
Ok(p)
325+
}
326+
}
314327
}

crates/vite_cli_snapshots/tests/cli_snapshots/main.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ struct StepConfig {
8686
/// that specifically assert non-TTY behaviour. Interactions require a PTY.
8787
#[serde(default = "default_true")]
8888
tty: bool,
89+
/// By default a failing step stops the case (shell-like `&&` semantics);
90+
/// set true when later steps deliberately inspect post-failure state.
91+
#[serde(default, rename = "continue-on-failure")]
92+
continue_on_failure: bool,
8993
}
9094

9195
impl Step {
@@ -188,6 +192,13 @@ impl Step {
188192
Self::Simple(_) => true,
189193
}
190194
}
195+
196+
const fn continue_on_failure(&self) -> bool {
197+
match self {
198+
Self::Detailed(config) => config.continue_on_failure,
199+
Self::Simple(_) => false,
200+
}
201+
}
191202
}
192203

193204
#[derive(serde::Deserialize, Debug, Clone)]
@@ -650,7 +661,7 @@ fn run_case(
650661
}
651662

652663
let mut timeout_error: Option<String> = None;
653-
for step in &case.steps {
664+
for (step_index, step) in case.steps.iter().enumerate() {
654665
let argv = step.argv();
655666
assert!(!argv.is_empty(), "step argv must not be empty");
656667

@@ -828,6 +839,15 @@ fn run_case(
828839
);
829840
doc.push_str(&redacted);
830841
}
842+
843+
// Shell-like `&&` semantics: a failing step stops the case unless it
844+
// opts out, so later steps never bless output from a broken setup.
845+
if !succeeded && !step.continue_on_failure() {
846+
if step_index + 1 < case.steps.len() {
847+
doc.push_str("\n*(remaining steps skipped: step failed)*\n");
848+
}
849+
break;
850+
}
831851
}
832852

833853
// Cleanup steps: best-effort, never snapshotted. Per-step envs apply

crates/vite_cli_snapshots/tests/cli_snapshots/redact.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ static VERSION_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
2424
});
2525
static THREAD_RE: LazyLock<regex::Regex> =
2626
LazyLock::new(|| regex::Regex::new(r"\d+ threads").unwrap());
27+
// Some tool banners print runtime versions bare ("Node 24.18.0 pnpm 10.34.4"
28+
// in vp create); mask those by tool-name context so user semver elsewhere
29+
// stays assertable.
30+
static TOOL_VERSION_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
31+
regex::Regex::new(
32+
r"\b((?i:node(?:\.js)?|npm|pnpm|yarn|bun|deno))([ /]+)\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b",
33+
)
34+
.unwrap()
35+
});
2736
// Output bytes differ across OSes (line endings, embedded paths), so byte
2837
// sizes and content-derived asset hashes can never be part of a shared
2938
// snapshot. The unit is kept ("<size> kB"): it only changes when content
@@ -153,6 +162,9 @@ pub fn redact_output(
153162
// Redact semver-shaped versions (bundled tool versions, Node versions).
154163
output = VERSION_RE.replace_all(&output, "<version>").into_owned();
155164

165+
// Redact bare runtime-tool versions by name context (see TOOL_VERSION_RE)
166+
output = TOOL_VERSION_RE.replace_all(&output, "$1$2<version>").into_owned();
167+
156168
// Redact thread counts like "16 threads" to "<n> threads"
157169
output = THREAD_RE.replace_all(&output, "<n> threads").into_owned();
158170

crates/vite_cli_snapshots/tests/redact_unit.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ fn masks_only_v_prefixed_versions() {
4545
);
4646
}
4747

48+
#[test]
49+
fn masks_bare_runtime_tool_versions_by_name_context() {
50+
// vp create prints these without the v prefix.
51+
let input = "Node 24.18.0 pnpm 10.34.4 (agent npm/11.4.2)\n".to_owned();
52+
assert_eq!(
53+
redact_output(input, &[], true),
54+
"Node <version> pnpm <version> (agent npm/<version>)\n"
55+
);
56+
}
57+
4858
#[test]
4959
fn replaces_paths_with_labels() {
5060
let input = "built /tmp/stage-1/dist in 3ms\n".to_owned();

packages/tools/src/__tests__/migrate-snap-tests.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ describe('translateCommand', () => {
8282
expect(isTodo(translateCommand('PATH=$PATH vp check', ctx()))).toBe(true);
8383
});
8484

85+
it('marks only the line-final step continue-on-failure', () => {
86+
// Legacy lines were independent; && within a line short-circuited.
87+
const steps = translateCommand('vp add x && cat package.json', ctx());
88+
expect(steps).toHaveLength(2);
89+
expect(steps[0].continueOnFailure).toBeUndefined();
90+
expect(steps[1].continueOnFailure).toBe(true);
91+
});
92+
93+
it('TODOs ls flags that list-dir does not replicate', () => {
94+
expect(isTodo(translateCommand('ls -la node_modules', ctx()))).toBe(true);
95+
});
96+
8597
it('turns leading cd chains into step cwd', () => {
8698
const steps = translateCommand('cd packages/web && vp run build', ctx());
8799
expect(steps).toHaveLength(1);

packages/tools/src/migrate-snap-tests.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface NewStep {
2525
comment?: string;
2626
cwd?: string;
2727
envs?: [string, string][];
28+
continueOnFailure?: boolean;
2829
}
2930

3031
/** New-harness fixture/case names allow only `[A-Za-z0-9_]`. */
@@ -282,6 +283,9 @@ function translateSimple(command: string, ctx: TranslationContext): NewStep | nu
282283
} else {
283284
return todo('unsupported chmod invocation');
284285
}
286+
} else if (program === 'ls' && args.some((a) => a.startsWith('-'))) {
287+
// ls flags (-a, -l, ...) change semantics list-dir does not replicate.
288+
return todo('ls flags need hand conversion');
285289
} else if (program in COREUTILS_MAP) {
286290
step = { argv: ['vpt', COREUTILS_MAP[program], ...args.filter((a) => !a.startsWith('-'))] };
287291
} else {
@@ -348,6 +352,7 @@ function translateCommand(raw: string, ctx: TranslationContext): NewStep[] {
348352
if (comment) {
349353
step.comment = comment;
350354
}
355+
step.continueOnFailure = true;
351356
ctx.notes.push(`folded \`&& echo\` into stat-file assertion: \`${command}\``);
352357
return [step];
353358
}
@@ -378,6 +383,13 @@ function translateCommand(raw: string, ctx: TranslationContext): NewStep[] {
378383
if (comment && steps.length > 0) {
379384
steps[0].comment = steps[0].comment ? `${comment}; ${steps[0].comment}` : comment;
380385
}
386+
// Legacy command LINES were independent (a failure did not stop the next
387+
// line), while `&&` within a line short-circuited. The harness stops on
388+
// failure by default, so only the line-final step opts back out; chain-
389+
// internal failures still stop, exactly like the shell did.
390+
if (steps.length > 0) {
391+
steps[steps.length - 1].continueOnFailure = true;
392+
}
381393
return steps;
382394
}
383395

@@ -394,6 +406,7 @@ function emitStep(step: NewStep, extra: { timeout?: number; snapshot?: boolean }
394406
step.comment === undefined &&
395407
step.cwd === undefined &&
396408
step.envs === undefined &&
409+
step.continueOnFailure !== true &&
397410
extra.timeout === undefined &&
398411
extra.snapshot === undefined;
399412
const argv = `[${step.argv.map(tomlString).join(', ')}]`;
@@ -417,6 +430,9 @@ function emitStep(step: NewStep, extra: { timeout?: number; snapshot?: boolean }
417430
if (extra.snapshot !== undefined) {
418431
fields.push(`snapshot = ${String(extra.snapshot)}`);
419432
}
433+
if (step.continueOnFailure === true) {
434+
fields.push('continue-on-failure = true');
435+
}
420436
return ` { ${fields.join(', ')} },`;
421437
}
422438

rfcs/interactive-snapshot-tests.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ The per-case bin dir fronts `packages/cli/bin` (the JS dispatch), which requires
232232
- **PTY and screen.** Each step spawns in a fresh PTY with a 500x500 grid (large enough that wrapping and scrolling do not distort output; scrollback capture is an open question for outputs beyond 500 rows). Output feeds a vt100 emulator continuously; screens are read from the emulator, never from raw bytes. Plain capture flattens all styling; `formatted-snapshot = true` preserves SGR color codes (rendered as escaped `\x1b[31m` text) for cases that assert color behavior, with the ConPTY parity fix (bare SGR-reset stripping) inherited from `pty_terminal`.
233233
- **Environment.** The child env is cleared and rebuilt: fixed baseline (`VP_CLI_TEST=1`, `VP_EMIT_MILESTONES=1`, `TERM=xterm-256color`, git identity, temp `VP_HOME`, temp `NPM_CONFIG_PREFIX`, controlled `HOME`), a small platform allow-list (Windows needs `SYSTEMROOT`, `APPDATA`, etc.), then case `env`/`unset-env`, then step `envs`. Today's ~40-pattern passthrough allow-list and its leakage risks go away. Notably absent from the baseline: `CI=true` and `NO_COLOR` (the PTY makes real interactive behavior the default, and the grid render makes color stripping unnecessary).
234234
- **Timeouts.** Per-step, default 50s (120s for `local-registry` cases), overridable per step. On timeout the child is killed, the exit is recorded as `timeout`, and remaining steps are skipped. No leaked processes.
235-
- **Exit codes.** Recorded in the snapshot when nonzero. Old `&&` chains that encoded "this must succeed before the next step" translate to sequential steps whose exit codes are asserted by the snapshot itself.
235+
- **Exit codes.** Recorded in the snapshot when nonzero. A failing step stops the case by default (shell-like `&&` semantics), so later steps never bless output from a broken setup; `continue-on-failure = true` opts a step out for deliberate post-failure inspection. The migrator preserves legacy semantics exactly: `&&` chain members keep the stopping default, while each legacy command line's final step gets the opt-out, because old lines were independent.
236236
- **Concurrency.** `libtest-mimic` runs trials in parallel. vite-task serializes on Linux because of ctrl-c signal-routing flakiness in parallel PTY tests; with a corpus this size we should scope that serialization to signal-sensitive cases (a `serial-signals` marker or a dedicated shard) rather than the whole suite. This needs measurement during implementation.
237237

238238
## Milestone protocol and CLI instrumentation

0 commit comments

Comments
 (0)