Skip to content

fix(bundle): atomic re-commit via per-entry aside (compatible with #162)#196

Closed
SahilRakhaiya05 wants to merge 71 commits into
TestSprite:mainfrom
SahilRakhaiya05:fix/bundle-atomic-recommit
Closed

fix(bundle): atomic re-commit via per-entry aside (compatible with #162)#196
SahilRakhaiya05 wants to merge 71 commits into
TestSprite:mainfrom
SahilRakhaiya05:fix/bundle-atomic-recommit

Conversation

@SahilRakhaiya05

@SahilRakhaiya05 SahilRakhaiya05 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Re-running test failure get --out <dir> can delete meta.json mid-commit; a failed re-commit then leaves the directory without a usable bundle.

This redesigns commitBundle to be compatible with #162 (isBundleOwnedEntry): --out may contain the user's unrelated files, so a whole-directory swap is not safe.

Approach

  1. Stage the complete bundle in <dir>/.tmp/ (unchanged)
  2. Move only bundle-owned entries aside to a sibling .<dir>.aside.<uuid>/ directory
  3. Install new artifacts from .tmp/; meta.json lands last (completion signal)
  4. On failure, restore aside entries (rollback) - prior bundle survives
  5. On success, best-effort aside cleanup (cleanup failure does not roll back)

Foreign files (e.g. notes.txt, src/) are never touched.

Testing

Split from #8 per review feedback.

Summary by CodeRabbit

  • New Features

    • Improved bundle installation for --out directories that already include unrelated files.
  • Bug Fixes

    • Preserves unrelated files during bundle updates.
    • Adds safer rollback behavior when a commit/install step fails, keeping the prior bundle intact.
    • Improves cleanup of temporary install state after both success and failure scenarios.
  • Tests

    • Added coverage for rollback and cleanup edge cases during bundle commit operations.

zeshi-du and others added 30 commits June 11, 2026 04:49
The Lint & Format job runs prettier --check over workflow YAML; the
aligned trailing comment failed it on every push.
Updated the README with a new link and added a video description.
- prettier-clean the README video block (fixes CI format:check on main)
- bump version to 0.1.1
- CHANGELOG: add [0.1.1] docs-only entry

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
NPM_TOKEN secret was never configured, so tag-triggered releases failed
with ENEEDAUTH. Auth now uses npm trusted publishing (configured on
npmjs.com for this repo + release.yaml):

- drop NODE_AUTH_TOKEN env (empty token would shadow OIDC auth)
- upgrade npm to >= 11.5.1 (trusted publishing requirement; Node 22 bundles 10.x)
- bump checkout/setup-node to v5 ahead of the June 16 Node 20 runner cutoff
ci(release): switch npm publish to OIDC trusted publishing
- Onboarding consolidated into `testsprite setup` (formerly `init`); the
  granular auth commands remain as hidden, deprecated aliases.
- CLI reports its version in the User-Agent header.
- README: the launch video no longer renders as a bare URL on npm.
create-batch --run launched its first concurrencyLimit triggers in
parallel, but the steady-state loop awaited each subsequent job to
fully finish (trigger + full --wait poll) before launching the next
one. Effective concurrency dropped to 1 after the initial wave
regardless of --max-concurrency.

Switch to the launch-then-race pattern already used by the other
three fan-outs in this file: launch up to the limit, relaunch on
each completion via startNext(), never await a whole job inline.

Add a regression test using equal-delay trigger responses so the
first wave settles in the same microtask batch, which is the exact
condition that exposed the bug.
…urrency

fix(test): prevent batch-run scheduler from serializing after first wave
…estSprite#7)

test code get --out <path> opened (truncated) the destination file
before the network request. If the GET then failed, or hit the
"no code generated yet" branch which writes nothing, the user's
pre-existing --out file was left emptied with no way to recover it.

Write to a sibling temp file instead and rename it onto the real
path only after a successful, complete write. Mirrors the atomic
rename contract bundle.ts already uses for multi-file bundles.

Add regression tests for both failure modes: a failing fetch and
the no-code-yet branch. Both reproduce the truncation on the old
code and pass on the fix.
…ite#9)

Batch-rerun chunks (>50 testIds) were dispatched concurrently via
Promise.all. The backend's producer/teardown closure dedup happens
per-request, not across requests, so two concurrent chunks sharing a
project's producer could each independently decide to trigger it,
double-running the producer or teardown.

Dispatch chunks sequentially in both the initial and deferred-retry
paths, closing the race at the source. Also dedupe the aggregated
accepted[] by testId and merge closure.byProject across chunks as a
defensive second layer, warning on stderr if a duplicate trigger is
detected.

Fixed a pre-existing test whose fixture relied on the old
double-counting behavior (same accepted entry returned from every
retry call).
…RROR (TestSprite#19)

A malformed API endpoint produced an opaque or misleading failure instead
of a clear config error:

  --endpoint-url "not a url"   -> `Error: Invalid URL` (exit 1, a raw
                                  `new URL()` throw with no guidance)
  --endpoint-url "localhost:3000" (missing scheme, parses as scheme
                                  "localhost:") and "ftp://x" (wrong scheme)
                               -> `fetch failed` / Service unavailable,
                                  emitted only after a full retry+backoff
                                  cycle — looks like a network outage, not a
                                  config typo.

Add `assertValidEndpointUrl` in client-factory.ts and run it in both the
real and dry-run paths of `makeHttpClient`, on the resolved endpoint (so it
covers --endpoint-url, TESTSPRITE_API_URL, and the credentials file). A
malformed value now throws a typed VALIDATION_ERROR (exit 5) with an
actionable message.

Crucially, and unlike the `--target-url` SSRF guard, this does NOT reject
localhost or private hosts — the API endpoint legitimately points at a
self-hosted, local-dev, or mock backend. Only syntactically invalid values
(unparseable, or a non-http(s) scheme) are rejected, so existing
self-hosted/CI configs and the test suite's localhost mock backend are
unaffected.

Adds unit coverage for assertValidEndpointUrl and the two makeHttpClient
paths, plus subprocess regressions.
runFailureGet now resolves and validates --out via resolveBundleDir and assertOutDirParentExists before calling GET /tests/{id}/failure, matching runArtifactGet and runCodeGet fast-fail behavior.

Adds regression tests asserting zero fetch calls on empty --out and missing parent dir paths.
…on (TestSprite#21)

A profile name (`--profile` / `TESTSPRITE_PROFILE`) is written verbatim as
an INI section header (`[name]`) in `~/.testsprite/credentials`, but was
never validated. A name containing the characters that break that grammar
silently corrupted the file:

  --profile "prod]"   -> serialises to `[prod]]`, which the section regex
                         cannot match, so the api_key/api_url lines that
                         follow are DROPPED on read. `setup` reports success
                         while the credential never persists.
  --profile $'a\nb'   -> the newline splits the header across two lines.
  --profile "  x  "   -> does not round-trip (the parser trims section
                         names, so it reads back as `x`).

Add `assertValidProfileName` in credentials.ts (a conservative allowlist:
letters, digits, dot, underscore, hyphen — covering `default`, `prod`,
`ci-staging`, `team.qa`) and call it from every profile-keyed entry point
(`readProfile`, `writeProfile`, `deleteProfile`). A malformed name now
throws a typed VALIDATION_ERROR (exit 5) before any file write, instead of
silently corrupting or failing to persist credentials.

Adds unit coverage for the guard and the three entry points, plus a
subprocess regression.

Co-authored-by: Zeshi Du <duke.zeshi@gmail.com>
…t json (TestSprite#22)

When a subcommand fired a parse error (unknown command, missing required
argument, invalid option), Commander's outputError callback wrote plain
text to stderr immediately and the catch block exited 5 with no further
output. A machine consumer that always parses stderr as JSON received an
unexpected plain-text string and crashed its JSON.parse.

Root cause: configureOutput was only applied to the root program, not to
subcommands. Each subcommand retained the default outputError that calls
write(str) directly. applyExitOverrideDeep now also propagates
configureOutput to every leaf so the message is buffered instead of
written.

In the CommanderError catch block, a resolved output mode is used to
either write a VALIDATION_ERROR JSON envelope or the buffered plain-text
message. An argv scan fallback handles the edge case where --output json
appears after the bad argument and was not yet parsed when the error fired.

The renderCommanderError helper is extracted to src/lib/render-error.ts
(alongside the existing rephraseUnknownOption helper) so it is unit-testable
without a subprocess. Eight unit tests cover JSON/text output, null fallback,
message trimming, and rephrased global-flag embedding. Four subprocess
regression tests in the [fix-5] block cover missing-arg, unknown subcommand,
argv-fallback, and text-mode no-regression paths.

Co-authored-by: zeshi-du <zeshi@testsprite.com>
New issue form for hackathon submissions — auto-applies the `hackathon`
label and captures the submitter's Discord identity for reward payout
coordination.
…rd (TestSprite#37)

assertNotLocal lowercased the hostname but did not strip a trailing dot, so http://localhost. (the FQDN form of localhost, RFC 6761) and http://localhost%2e bypassed the host === 'localhost' loopback check. IP literals are already dot-normalized by the WHATWG URL parser, so only named hosts were affected. Strips one trailing dot before the comparison. Adds 4 regression tests (3 blocked variants + 1 public-FQDN no-false-positive).
* fix(skill-nudge): require complete Codex managed section

* docs(skill-nudge): document helper contracts

---------

Co-authored-by: ahndohun <19940813+ahndohun@users.noreply.github.com>
TestSprite#36)

project create/update validated --name with the action handler's if (!name) check, which a whitespace-only string passes (a non-empty string is truthy). The blank name was then sent verbatim, creating a junk-named project. The sibling 	est create already rejects this via the requireString whitespace guard (dogfood P1 fix TestSprite#1); this aligns project create/update with that behavior. Adds 2 regression tests.
…Sprite#14)

Only `test` and `project` validated the global `--output` flag. The
`auth`, `usage`, `agent`, and `init` command groups resolved it with
`globals.output ?? 'text'`, so an unrecognised value (e.g. a typo like
`--output josn`) was silently coerced to text mode instead of being
rejected. A coding agent that asked for `--output json` then received a
human-readable text payload and failed to parse it as JSON, with no
signal as to why.

Extract the validation into a shared `resolveOutputMode` helper in
`lib/output.js` and route every command group's `resolveCommonOptions`
through it. Invalid values now throw a typed VALIDATION_ERROR (exit 5)
with an actionable message everywhere. This also unifies the error
wording, which previously differed between `test`
("Flag `--output` is invalid: must be one of: json, text.") and
`project` ("--output must be one of: json, text").
Davidson3556 and others added 21 commits July 5, 2026 12:31
…Sprite#166)

A successful (200) response whose body is not JSON — a misconfigured
endpoint, a proxy / captive-portal / login page returning HTML with a
success status, or an empty body — caused the OK path to call raw
`response.json()`, whose SyntaxError escaped to the top-level handler.
That produced an opaque exit 1 and, under --output json, a bare
`{"error":"<parse message>"}` that breaks the typed-envelope contract
every other error honors (the non-OK path already reads defensively via
safeReadJson).

Wrap the OK-path parse failure in a typed INTERNAL ApiError (exit 1
unchanged) that carries the requestId, names the likely cause, and
points the operator at their endpoint config; details include the HTTP
status, content-type, and underlying parse message. Abort/timeout
errors mid-read keep their existing classification.

Fixes TestSprite#94
)

* feat(test): JUnit XML report export for batch --wait runs

* fix(ci): prettier formatting and help snapshot alignment for report flags

* fix(test): address CodeRabbit review on JUnit report export

* fix(test): add projectId to CliBatchRunFreshResult for JUnit inference
TestSprite#33)

pollAccepted in runTestRerun hardcoded exitCode:1 on ApiError, causing
the auth-escalation find(r => r.error?.exitCode === 3) to always return
undefined -- auth failures silently exited 1 instead of 3.

- preserve err.exitCode in pollAccepted (mirrors runTestRunAll fix)
- add auth escalation block before generic exit-1 throw
- bound initial chunk idempotency key to <=256 chars (mirrors retry path)
* feat(agent): add kiro as an install target

Adds kiro as an own-file agent target on the current multi-skill model: AgentTarget union, a pathFor('kiro') case landing at .kiro/skills/<skill>/SKILL.md, and a TARGETS entry (experimental, own-file, wrapSkill frontmatter like claude/antigravity). Kiro installs both default skills (testsprite-verify + testsprite-onboard). Updates the --target help string, README/DOCUMENTATION target lists and counts, unit + command tests (six keys, list 12 rows, five own-file targets, 10 dry-run would-write lines, renderForTarget + content-integrity coverage), and regenerates the agent/setup help snapshots. Rebuilt on current main.

* test(e2e): include kiro in target matrix guards and multi-target install

The Local E2E Tests CI job failed because two matrix-coverage guards hardcoded the target set (claude, antigravity, cursor, cline, codex) and did not include the new kiro target. Add kiro (own-file, between cline and codex to match TARGETS order) to both guards, and add kiro to the multi-target install e2e so the target is actually exercised end-to-end.
…nd test wait (TestSprite#153)

Apply the fix in src/commands/ (the compiled CLI path). When the overall
--timeout polling deadline is exceeded, emit {runId, status:"running"} to
stdout before exit 7 so JSON agents can chain into test wait.

Co-authored-by: Contributor <contributor@testsprite.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ressions (TestSprite#168)

* feat(test): add "test diff <run-a> <run-b>" to isolate run-to-run regressions

* test(diff): cover runDiff --dry-run branch (offline canned sample)
… files in --out dir (TestSprite#162)

commitBundle's stale-file sweep removed EVERY directory entry not part
of the fresh bundle, so 'test failure get --out <dir>' / 'test artifact
get --out <dir>' pointed at a pre-existing, populated directory silently
deleted the user's unrelated files (exit 0, no warning) — on the very
first write, not just re-commits.

Scope the sweep to entries the bundle format owns (result.json,
failure.json, video.mp4, meta.json, steps, .tmp, .partial, code.<ext>).
Stale bundle files are still cleaned — an old video.mp4 when the new
bundle has no video, a code.py when the new bundle writes code.ts — but
foreign files and directories are never touched.

Fixes TestSprite#159

Co-authored-by: Kshitij Bhardwaj <tothemoon202154@outlook.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…al without --all (TestSprite#163)

Two silent-footgun gaps in test rerun's flag validation:

1. Explicit test IDs combined with --all silently discarded the listed
   IDs — the --all branch resolves the full project test set and
   overwrites them — so 'rerun test_abc --all' dispatched a batch rerun
   of EVERY test in the project, burning rerun/auto-heal credits with
   no error. Both siblings already guard this exact ambiguity (test
   run's positional+--all guard, delete-batch's ids+--all guard).

2. --status <list> and --skip-terminal without --all were silently
   ignored — including INVALID --status values, which were never
   validated — while the same misuse of rerun's own --filter (and
   delete-batch's --status) exits 5. All three narrowing filters now
   share the same guard.

Both reject with VALIDATION_ERROR (exit 5) before any network dispatch.

Fixes TestSprite#160

Co-authored-by: Kshitij Bhardwaj <tothemoon202154@outlook.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…e#171)

The 'testsprite usage' command help text listed three examples but
omitted the --debug flag (a global option useful for diagnosing auth
and network issues). It also didn't document the exit codes, which
matters for CI/CD scripts that gate on the return value.

This PR adds:
- A --debug example showing what it traces (HTTP method/path, request
  id, latency) — useful when debugging 'auth error' or 'transport
  failure' messages.
- An explicit exit-codes section (0 success, 3 auth error, 10 network
  failure) so users scripting against the CLI know what to expect.

Pure documentation improvement — no behavioral change, no new deps.
Tested: existing unit tests pass (npm test).

Co-authored-by: MOAAMN SAYED <moaamnsayed560@gmail.com>
…t pure) (TestSprite#31)

Interactive prompts (`prompt.ts` — the API-key prompt during `setup`/`auth
configure`, the target prompt during `agent install`) wrote the question and
masking to STDOUT, and the "Configuring profile …" prelude defaulted to
stdout too. On the interactive path that mixes UI text into stdout — and
under `--output json` it breaks the contract that stdout is a single JSON
document, so a consumer doing `JSON.parse(stdout)` fails.

Default both to stderr: prompts and informational preludes are interactive
UI, not result data. stdout now carries only the command's result (the §8.1
stdout-purity principle the repo already enforces elsewhere). stderr is still
the user's TTY, so prompts remain visible; the secret is still never echoed.
Callers that inject explicit streams are unaffected.

Adds regression tests: promptText writes the question to stderr by default,
and the configure prelude lands on stderr (not the result stdout).
* block artifact download redirects

* redact artifact download urls

---------

Co-authored-by: merlinsantiago982-cmd <merlinsantiago982-cmd@users.noreply.github.com>
* fix(test): validate artifact run id default path

* fix(test): reject windows dot artifact run ids

* fix(test): reject windows dot-suffix artifact run ids

* style(test): format artifact run id guard

---------

Co-authored-by: Lexiie <28455136+Lexiie@users.noreply.github.com>
…TestSprite#11)

* feat(cli): add runtime Node.js version check with clear error message

* refactor(version-guard): extract to a documented module tested against the real implementation
Windsurf (Cascade) reads workspace rules from `.windsurf/rules/*.md`. Add it
as an own-file agent target so `testsprite agent install --target windsurf`
(and `setup --agent windsurf`) installs the TestSprite skills into a Windsurf
project. Reworked onto the v0.2.0 multi-skill agent-targets API (pathFor /
SKILLS / DEFAULT_SKILLS).

Rule files use Cascade frontmatter with `trigger: model_decision` — the
equivalent of the Cursor `.mdc` `alwaysApply: false` mode (description shown
up front; full body pulled in on relevance).

Budget handling: a `.windsurf/rules/*.md` file caps at ~12 K characters and
Cascade silently truncates beyond it, which would cut the full ~22 KB verify
skill in half. The windsurf target therefore renders the COMPACT body per
skill (new `compactBody` flag + `compactBodyFor`): a skill that ships a
trimmed codex asset (`testsprite-verify` → ~5 KB) uses it, while a skill whose
codex contribution is only a one-liner (`testsprite-onboard`, ~6.5 KB full)
keeps its full body — both land well under the cap. `agent.ts` and
`renderForTarget` select the same body so installed bytes match the render.

Everything else derives from the TARGETS map automatically (agent list, the
setup --agent choices, skill-nudge install detection). Updated the hardcoded
help strings, the --help snapshot, the agent-targets/agent unit tests (incl.
Cascade-frontmatter and per-skill budget tests), the e2e matrix guards /
content-integrity (gated on compactBody), and the README/DOCUMENTATION target
lists (incl. the --force own-file list).
… CI proxies (TestSprite#169)

* feat(cli): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY behind corporate and CI proxies

* fix(proxy): degrade to default dispatcher when proxy agent init fails instead of crashing startup

* fix(proxy): pin undici to ^7.16.0 for Node 20 compatibility (8.x requires Node >=22.19)
…e#132)

* feat(cli): add 'test flaky' repeat-run flaky-test detector

* fix(flaky): cap --runs at 10 per maintainer scope (TestSprite#115)

Rescope the flaky detector's --runs bound from 1-100 to 1-10 as requested in the TestSprite#115 triage: uncapped FE replays amplify free executions. Updates the MAX_FLAKY_RUNS constant (which drives the validation, error message, and --runs help text), docs, changelog, and the runs-bound tests. Regenerates the help snapshot, which also adds the previously-missing 'test flaky' entry.

* test(snapshot): refresh flaky help snapshot after rebase onto main

Rebasing onto current main (which added the global --request-timeout option, TestSprite#17) changes the 'Global options' line rendered in the test flaky --help output. Regenerate the snapshot so the help snapshot test stays green on CI.

* docs(changelog): resolve leftover merge-conflict markers (keep JUnit + flaky 1-10)
)

* feat(test): add "test lint" offline plan/steps validator

* fix(lint): report physical JSONL line numbers (blank lines no longer shift them)
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6aeff514-b4b1-4428-97d5-f33b0d59a8d0

📥 Commits

Reviewing files that changed from the base of the PR and between 6264d78 and fb69d6c.

📒 Files selected for processing (1)
  • src/lib/bundle.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/bundle.ts

Walkthrough

commitBundle now stages bundle updates through a uniquely named aside directory, restores prior bundle-owned entries on failure, and leaves foreign files in place. The PR also adds a pathExists helper and Vitest coverage for rollback, success, and aside cleanup failure.

Changes

commitBundle aside/rollback rework

Layer / File(s) Summary
Aside-based install and rollback implementation
src/lib/bundle.ts
Imports add randomUUID and reorder path imports; commitBundle moves existing bundle-owned entries into a uniquely-named aside directory, installs staged steps/ and files in a defined order with meta.json last, rolls back from aside on failure, and cleans up .partial/.tmp/aside afterward; a new pathExists helper checks existence via stat.
End-to-end commitBundle test suite
src/lib/bundle.commit.test.ts
A new Vitest suite mocks rename/rm, sets up staged vs prior bundle fixtures, and asserts rollback on staged rename failure, preservation of foreign files on success, and success despite aside cleanup rm failures.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • TestSprite/testsprite-cli#162: Both PRs modify commitBundle's stale/bundle-owned entry cleanup logic to avoid touching unrelated pre-existing files.

Suggested reviewers: ruili-testsprite, zeshi-du

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: making bundle re-commit atomic via aside handling for existing output directories.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/lib/bundle.commit.test.ts (1)

72-92: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add coverage for failure after a new artifact is installed.

The rollback test only fails on the first staged rename (result.json), so no artifact is installed before the throw. It does not exercise the case where a new artifact installs successfully and a later rename (e.g. meta.json) fails — which is where a stitched old-meta/new-artifact bundle can survive rollback (see the commitBundle rollback comment). Consider adding a case that throws on the meta.json rename and asserts that no new-run artifact (e.g. result.json) remains alongside the restored old meta.json.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/bundle.commit.test.ts` around lines 72 - 92, The current rollback
test in commitBundle only covers a failure on the first staged rename, so it
never verifies rollback after a new artifact has already been installed. Add a
second case in src/lib/bundle.commit.test.ts using commitBundle and the existing
renameMock/seedBundleDirs helpers that throws on the meta.json rename after
allowing result.json to install, then assert the prior bundle is fully restored
and no new-run artifact like result.json remains alongside the old meta.json.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/bundle.ts`:
- Around line 578-627: The rollback logic in the bundle commit flow does not
remove newly installed destination files when a later rename fails, so a failed
update can leave mixed bundle contents behind. Update the rollback path in
src/lib/bundle.ts to track every destination written by the commit sequence in
the main try block, especially the steps directory and top-level files renamed
from tmpDir, and ensure rollback deletes those installed targets before
restoring aside entries. Keep the fix localized around rollback, asideIfPresent,
and the rename/unlink steps so the bundle returns to a clean pre-commit state on
any error.

---

Nitpick comments:
In `@src/lib/bundle.commit.test.ts`:
- Around line 72-92: The current rollback test in commitBundle only covers a
failure on the first staged rename, so it never verifies rollback after a new
artifact has already been installed. Add a second case in
src/lib/bundle.commit.test.ts using commitBundle and the existing
renameMock/seedBundleDirs helpers that throws on the meta.json rename after
allowing result.json to install, then assert the prior bundle is fully restored
and no new-run artifact like result.json remains alongside the old meta.json.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ee143ef-3954-461d-8a1b-f9362b62f177

📥 Commits

Reviewing files that changed from the base of the PR and between b9e9601 and 6264d78.

📒 Files selected for processing (2)
  • src/lib/bundle.commit.test.ts
  • src/lib/bundle.ts

Comment thread src/lib/bundle.ts
Comment on lines +578 to +627
const rollback = async (): Promise<void> => {
for (const { asidePath, restorePath } of [...asideLog].reverse()) {
await rm(restorePath, { recursive: true, force: true }).catch(() => undefined);
await rename(asidePath, restorePath).catch(() => undefined);
}
await rm(asideDir, { recursive: true, force: true }).catch(() => undefined);
};

// (4) Top-level files (result/failure/code/video). meta.json renames
// LAST; track it separately.
const metaIdx = topLevel.indexOf('meta.json');
const beforeMeta = metaIdx >= 0 ? topLevel.filter((_, i) => i !== metaIdx) : topLevel;
for (const file of beforeMeta) {
await rename(join(tmpDir, file), join(dir, file));
}
try {
const topLevel = files.filter(f => !f.startsWith('steps/'));
const newTopLevelSet = new Set(topLevel);
newTopLevelSet.add('meta.json');

const existing = await readdir(dir).catch(() => [] as string[]);
for (const entry of existing) {
if (entry === '.tmp') continue;
if (!isBundleOwnedEntry(entry)) continue;

const isStale = entry !== 'steps' && entry !== '.partial' && !newTopLevelSet.has(entry);
const willReplace = entry === 'steps' || newTopLevelSet.has(entry);
if (isStale || willReplace) {
await asideIfPresent(entry);
}
}

// (5) meta.json LAST → atomic completion signal.
if (metaIdx >= 0) {
await rename(join(tmpDir, 'meta.json'), join(dir, 'meta.json'));
}
const stepsTmp = join(tmpDir, 'steps');
const stepsDir = join(dir, 'steps');
if (await dirExists(stepsTmp)) {
await rename(stepsTmp, stepsDir);
}

// .partial from a prior aborted run is now stale. Remove it so an
// agent inspecting the dir sees only the fresh bundle.
await unlink(join(dir, '.partial')).catch(() => undefined);
const metaIdx = topLevel.indexOf('meta.json');
const beforeMeta = metaIdx >= 0 ? topLevel.filter((_, i) => i !== metaIdx) : topLevel;
for (const file of beforeMeta) {
await rename(join(tmpDir, file), join(dir, file));
}

// Clean up the now-empty tmp dir.
await rm(tmpDir, { recursive: true, force: true });
if (metaIdx >= 0) {
await rename(join(tmpDir, 'meta.json'), join(dir, 'meta.json'));
}

await unlink(join(dir, '.partial')).catch(() => undefined);
await rm(tmpDir, { recursive: true, force: true });
// Best-effort aside cleanup — failures must not roll back a committed bundle.
await rm(asideDir, { recursive: true, force: true }).catch(() => undefined);
} catch (err) {
await rollback();
await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
throw err;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant structure in src/lib/bundle.ts and inspect the surrounding logic.
ast-grep outline src/lib/bundle.ts --view expanded || true

echo '--- bundle.ts around the commit/rollback section ---'
sed -n '500,700p' src/lib/bundle.ts

echo '--- tests mentioning partial/rollback/bundle integrity ---'
rg -n "partial|rollback|asideIfPresent|commitBundle|bundle integrity|stitch|meta.json|result.json" src -g '*.{ts,tsx,js,mjs,cjs}' -A 4 -B 4

Repository: TestSprite/testsprite-cli

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect tests specifically around bundle commit failure modes.
rg -n "result\.json|meta\.json|steps/|\.partial|rollback|rename" tests src -g '*.{ts,tsx,js,mjs,cjs}' -A 6 -B 6

# If tests exist in src/lib, inspect them with line numbers.
if [ -f src/lib/bundle.test.ts ]; then
  echo '--- src/lib/bundle.test.ts ---'
  sed -n '1,260p' src/lib/bundle.test.ts
fi
if [ -f src/lib/bundle.spec.ts ]; then
  echo '--- src/lib/bundle.spec.ts ---'
  sed -n '1,260p' src/lib/bundle.spec.ts
fi

Repository: TestSprite/testsprite-cli

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the bundle-related helper definitions referenced by the commit path.
ast-grep outline src/lib/bundle.ts --view expanded | sed -n '1,220p'

echo '--- helper definitions ---'
rg -n "function asideIfPresent|const asideIfPresent|async function asideIfPresent|dirExists|isBundleOwnedEntry|asideLog|tmpDir|asideDir" src/lib/bundle.ts -A 5 -B 5

echo '--- any exact-run integrity enforcement nearby ---'
rg -n "snapshotId|run-id|integrity|mismatch|reject" src/lib/bundle.ts src -g '*.{ts,tsx,js,mjs,cjs}' -A 4 -B 4

Repository: TestSprite/testsprite-cli

Length of output: 50382


Rollback must clean up newly installed bundle files (src/lib/bundle.ts:578-627)

If a later rename fails after result.json or steps/ has already been moved in, rollback() restores the aside entries but leaves those new files behind, which can mix two runs in the same bundle. Track installed destinations and delete them during rollback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/bundle.ts` around lines 578 - 627, The rollback logic in the bundle
commit flow does not remove newly installed destination files when a later
rename fails, so a failed update can leave mixed bundle contents behind. Update
the rollback path in src/lib/bundle.ts to track every destination written by the
commit sequence in the main try block, especially the steps directory and
top-level files renamed from tmpDir, and ensure rollback deletes those installed
targets before restoring aside entries. Keep the fix localized around rollback,
asideIfPresent, and the rename/unlink steps so the bundle returns to a clean
pre-commit state on any error.

Source: Path instructions

@zeshi-du zeshi-du left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is very close — the aside/rollback design is a real improvement over the old commit path (which had no rollback at all), and the #162 foreign-file contract is correctly preserved. One ordering issue before it can land:

meta.json must be the FIRST entry moved aside, not wherever readdir happens to place it.

The old commit path removed the old meta.json first, deliberately: its presence is the bundle's completion signal (§3/§7.3 — "no meta → bundle absent or mid-write, refuse to consume"). In this version the aside loop walks readdir(dir) order, which is platform-arbitrary. If steps/ is moved aside before meta.json, there's a window where a concurrent reader sees a present meta.json (⇒ "bundle complete") pointing at steps that are already gone — exactly the torn read the meta-first ordering existed to prevent.

Fix is one line: await asideIfPresent('meta.json') before the loop (and skip it inside the loop). Install-side ordering is already right (meta lands last).

Nit, non-blocking: asideDir lives in dirname(dir), so a read-only parent with a writable --out dir now fails the commit where it previously succeeded. The failure is caught and rolled back cleanly, so I'm fine with it — just noting the behavior change.

Happy to merge as soon as the meta-first ordering is in.

@jangjos-128

Copy link
Copy Markdown
Contributor

Hi @SahilRakhaiya05 — so sorry for the disruption here. 🙏 While we were publishing a new CLI release, a hiccup in our process unintentionally closed this PR. To be clear: your contribution wasn't rejected, and nothing on your end caused this.

Your work is completely safe — your branch and every commit are intact and untouched, and main is already back to normal on our side.

Because of how the closure happened, the cleanest path is a fresh PR from your existing branch (rather than reopening this one) — and opening it yourself keeps it under your name, with full credit for your work:

👉 Open a new PR from your branch

It should merge cleanly. So your work doesn't get lost, if we don't hear back in the next ~3 days we'll go ahead and open it for you — but opening it yourself keeps it under your name, and either way we're always happy to hand it back to you.

Thank you for contributing to TestSprite, and again, we're sorry for the hassle. We really appreciate this work and want to see it merged. 🙌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.