Skip to content

Add add-wizard integration coverage for manifest bootstrap ordering#47517

Merged
pelikhan merged 2 commits into
mainfrom
copilot/add-integration-tests
Jul 23, 2026
Merged

Add add-wizard integration coverage for manifest bootstrap ordering#47517
pelikhan merged 2 commits into
mainfrom
copilot/add-integration-tests

Conversation

Copilot AI commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PR #47462 changed add-wizard to surface aw.yml config before the generic engine setup flow, but that behavior was only covered by unit tests. This adds integration coverage that exercises the wizard end-to-end and verifies the bootstrap phase ordering at the terminal boundary.

  • Scope

    • Extend add_wizard_tuistory_integration_test.go with coverage for local package manifests that declare config actions.
    • Keep the existing tuistory-based add-wizard coverage intact while making it skip cleanly when gh auth is unavailable.
  • Manifest bootstrap flow

    • Add a local aw.yml package fixture with a pre-install repo-variable action and a post-install handoff action.
    • Assert that the pre-install prompt is shown before engine selection.
    • Assert that post-install messaging does not appear early.
  • Interactive harness

    • Add a PTY-backed test session for the manifest case so the wizard runs under a real interactive terminal.
    • Stub gh responses with a minimal fake CLI to isolate the flow under test from external GitHub state.
  • What this guards

    • Regressions where manifest-driven setup is delayed until after engine selection.
    • Regressions where late bootstrap actions leak into the early wizard phase.
config:
  - type: repo-variable
    name: OPTIONAL_BOOTSTRAP_VAR
    prompt: Enter optional bootstrap variable
    optional: true
  - type: handoff
    message: Post-install handoff should not appear before engine selection.

The new integration path verifies that Enter optional bootstrap variable is rendered before Which coding agent would you like to use?, and that the handoff message is absent at that point.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title Add integration tests for add-wizard manifest bootstrap flow Add add-wizard integration coverage for manifest bootstrap ordering Jul 23, 2026
Copilot AI requested a review from pelikhan July 23, 2026 07:32
@pelikhan
pelikhan marked this pull request as ready for review July 23, 2026 07:33
Copilot AI review requested due to automatic review settings July 23, 2026 07:33

Copilot AI 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.

Pull request overview

Adds end-to-end coverage for manifest bootstrap ordering in add-wizard.

Changes:

  • Adds a local manifest fixture with early and late bootstrap actions.
  • Adds a fake gh CLI and PTY-backed interactive test.
  • Improves authentication handling for existing integration coverage.
Show a summary per file
File Description
pkg/cli/add_wizard_tuistory_integration_test.go Tests manifest prompts before engine selection.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Medium

Comment on lines 214 to 218
output, err := runTuistory(t, "-s", sessionName, "wait", text, "--timeout", fmt.Sprintf("%d", timeoutMs))
if err != nil && strings.Contains(output, "requires an interactive terminal") {
t.Skipf("tuistory session is not interactive in this environment: %s", output)
}
require.NoError(t, err, "Expected tuistory to find %q. Output: %s", text, output)
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions github-actions Bot 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.

REQUEST_CHANGES — Four correctness bugs in the new PTY harness; all must be fixed before merge.

### Blocking issues (4)
  1. done channel consumed by waitForTextclose() always kills (line 259): done is a 1-buffered send channel. After waitForText drains it, close() blocks until the 2-second fallback fires and calls Kill() unconditionally. Replace with a closed chan struct{} + mutex-protected exit-error field.

  2. Race between cmd.Wait() and io.Copy — final output missed (line 229): cmd.Wait() can return while the PTY master buffer still holds unread bytes. readAll() called immediately after may miss the last lines, causing flaky "process exited before output contained X" failures.

  3. io.Copy goroutine never joined — data race under -race (line 207): close() closes the PTY but does not wait for the goroutine to exit. The goroutine can still write to session.output after the test returns, which is a data race.

  4. Kill() without subsequent Wait() — zombie process (line 262): SIGKILL is sent but exit status is never reaped, leaving a zombie on Linux and a leaked cmd.Wait() goroutine.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 37.8 AIC · ⌖ 5.13 AIC · ⊞ 5.7K
Comment /review to run again


go func() {
_, _ = io.Copy(&session.output, ptmx)
}()

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.

done channel is consumed by waitForText, so close() always hits the 2-second kill timeout.

💡 Details and suggested fix

done is a buffered channel of size 1. Any call to waitForText that receives from s.done drains the channel; when close() subsequently selects on case <-s.done it blocks until the 2-second fallback fires and calls Kill() — even when the process already exited cleanly. On slow CI this adds a guaranteed 2-second penalty per test and spuriously kills a process that may already be gone.

Fix: close a sentinel chan struct{} instead of sending to it, and store the exit error in a mutex-protected field:

type interactivePTYSession struct {
    cmd     *exec.Cmd
    ptmx    *os.File
    output  lockedBuffer
    done    chan struct{} // closed on exit, never sent to
    exitMu  sync.Mutex
    exitErr error
}

// goroutine:
go func() {
    err := cmd.Wait()
    s.exitMu.Lock()
    s.exitErr = err
    s.exitMu.Unlock()
    close(s.done)
}()

Both waitForText and close() can then observe exit independently via <-s.done without consuming each other's value.

func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.Write(p)

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.

Race condition: cmd.Wait() completes before io.Copy has drained the PTY buffer — final output lines can be missed.

💡 Details and suggested fix

When the child process exits, cmd.Wait() sends to s.done. waitForText immediately calls readAll() on receipt. However, the PTY master-side buffer can still contain bytes that the io.Copy goroutine has not yet written into session.output — there is no happens-before guarantee between cmd.Wait() returning and io.Copy finishing. This means waitForText can spuriously return "process exited before output contained X" even when X was actually emitted as one of the final lines.

Fix: drain the PTY after cmd.Wait() returns before signalling completion:

go func() {
    err := cmd.Wait()
    // Close ptmx to unblock io.Copy, then wait for it to drain
    _ = ptmx.Close()
    copyDone.Wait() // WaitGroup decremented in the io.Copy goroutine
    s.exitMu.Lock()
    s.exitErr = err
    s.exitMu.Unlock()
    close(s.done)
}()

This ensures readAll() sees the complete output when s.done is closed.

return &addWizardManifestTuistorySetup{
tempDir: tempDir,
binaryPath: globalBinaryPath,
packagePath: packagePath,

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.

io.Copy goroutine is never joined — goroutine leak and data race under -race.

💡 Details and suggested fix

close() calls ptmx.Close() which eventually causes io.Copy to return an error and exit, but there is no synchronisation point that waits for the goroutine to finish. The test function can return while the goroutine is still writing to session.output, which is a data race detectable with go test -race. Additionally, the goroutine outlives the test, so if the test process re-uses allocations, memory-safety guarantees break.

Fix with a sync.WaitGroup:

var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    _, _ = io.Copy(&session.output, ptmx)
}()

// in close():
_ = s.ptmx.Close()
wg.Wait() // guarantee goroutine has finished before returning

}()
go func() {
session.done <- cmd.Wait()
}()

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.

Kill() without a subsequent Wait() — zombie process and goroutine leak.

💡 Details and suggested fix

When the 2-second timeout fires, close() calls s.cmd.Process.Kill() but never calls cmd.Wait(). On Linux this leaves the child as a zombie (SIGKILL was delivered but the exit status was never reaped). The cmd.Wait() goroutine is still blocking in the background — it will eventually be unblocked by the OS, but there is no guarantee it returns before the test binary exits, constituting a goroutine leak.

Fix: drain s.done (with an additional short timeout) after killing:

case <-time.After(2 * time.Second):
    _ = s.cmd.Process.Kill()
    select {
    case <-s.done: // reap
    case <-time.After(500 * time.Millisecond):
    }

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 88/100 — Excellent

Analyzed 1 test: 1 design, 0 implementation, 0 violation(s).

📊 Metrics (1 test)
Metric Value
Analyzed 1 (Go: 1, JS: 0)
✅ Design 1 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 1 (100%)
Duplicate clusters 0
Inflation N/A (integration test, no production file)
🚨 Violations 0
Test Classification
Test File Classification Notes
TestTuistoryAddWizardManifestBootstrapRunsBeforeEngineSelection pkg/cli/add_wizard_tuistory_integration_test.go:394-444 Behavioral Design Test Enforces manifest bootstrap ordering invariant: config runs BEFORE engine selection.

Verdict

Passed. 0% implementation tests (threshold: 30%). No violations.

Test Quality Highlights:

  • Behavioral Contract: Test verifies a critical user-visible ordering constraint—pre-install bootstrap configuration must run before engine selection. This is a genuine design invariant.
  • Comprehensive Coverage: 11 assertions across 3 phases: (1) bootstrap prompt shown before engine prompt, (2) engine prompt arrives after bootstrap input, (3) strict index ordering verified.
  • Strong Assertion Messages: All assertions include descriptive failure context (e.g., "Pre-install bootstrap prompt should appear before engine selection").
  • Proper Error Handling: Uses require.NoError for critical timeouts; assert.* for state verification.
  • High Value: If deleted, a regression where prompts reorder would escape unit tests. This catches a subtle but important behavioral contract.
  • No Forbidden Patterns: Zero mocks libraries (gomock, testify/mock). External I/O mocks (fake gh script) are appropriate for integration testing.
  • Correct Build Tag: //go:build integration present on line 1 (mandatory for integration tests).

Token Budget: Analyzed 1 test function. Full file diff inspected.

🧪 Test quality analysis by Test Quality Sentinel · haiku45 20.3 AIC · ⌖ 7.45 AIC · ⊞ 7K ·
Comment /review to run again

@github-actions github-actions Bot 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.

✅ Test Quality Sentinel: 88/100. 0% implementation tests (threshold: 30%). No violations. Test enforces critical design invariant: bootstrap configuration must run before engine selection in add-wizard manifest workflows.

@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (290 new lines in pkg/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/47517-pty-backed-integration-test-harness-for-add-wizard-bootstrap.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI could not infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-47517: PTY-Backed Integration Test Harness for Add-Wizard Manifest Bootstrap Ordering

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 0042-use-postgresql.md for PR #42).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · sonnet46 49.5 AIC · ⌖ 12.9 AIC · ⊞ 8.5K ·
Comment /review to run again

@github-actions github-actions Bot 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.

Review: Add-wizard manifest bootstrap integration test

Three blocking issues found in the new PTY-backed test harness.

Issues:

  1. Line 188 — Bash case glob bug (blocking): The ? in ?per_page=100 is a bash shell wildcard, not a literal ?. Additionally, real gh api calls from bootstrap code pass query params via --field flags — the path argument won't contain ?per_page=100 — so this arm will never match, causing the fake gh to exit 1 and hard-fail the test.

  2. Line 248 — PTY failure hard-fails instead of skipping (blocking): If PTY allocation fails in headless CI, require.NoError marks the test as FAIL rather than SKIP.

  3. Line 279 — done channel drained by waitForText, breaking close() (blocking): Once waitForText consumes the exit error from the buffered channel, close() can never receive from it and always hits the 2-second kill timeout.

See inline comments for fix suggestions.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 64.1 AIC · ⌖ 4.85 AIC · ⊞ 5K

echo '{"permission":"write"}'
exit 0
;;
/repos/octo/example/actions/variables?per_page=100|/repos/octo/example/actions/secrets?per_page=100|/repos/octo/example/actions/secrets|/orgs/octo/actions/secrets)

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.

Bug: ? in bash case pattern is a glob wildcard, not a literal ?

The pattern /repos/octo/example/actions/variables?per_page=100 uses ? as a shell glob (matches any single character). Worse, the real gh api invocations from bootstrap code typically pass query params via --field flags — the path argument won't contain ?per_page=100 at all — so this arm will never match and the fake gh will exit 1 with unexpected gh invocation, hard-failing the test.

Fix by matching the path prefix with a trailing *:

/repos/octo/example/actions/variables*|/repos/octo/example/actions/secrets*|/orgs/octo/actions/secrets*)

@copilot please address this.

func startInteractivePTYSession(t *testing.T, cmd *exec.Cmd) *interactivePTYSession {
t.Helper()

ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 140, Rows: 40})

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.

PTY failure hard-fails the test instead of skipping

startInteractivePTYSession uses require.NoError on the pty.StartWithSize call. In a headless CI environment where PTY allocation fails (e.g., no controlling terminal), this will mark the test as failed rather than skipped — masking the real unavailability.

Consider converting to a skip similar to how the tuistory path handles this:

ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 140, Rows: 40})
if err != nil {
    t.Skipf("PTY not available in this environment: %v", err)
}

@copilot please address this.

}

select {
case err := <-s.done:

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.

Bug: done channel is drained by waitForText, causing close() to always time out

When the process exits during waitForText, the error is consumed from s.done (line 279). But close() also selects on s.done (line 311). Since done is a buffered channel of size 1 and was already drained, close() will never receive and will always hit the 2-second timeout, then kill the process (which may already be dead), leaking resources and adding 2 seconds to every test run that exercises this path.

Fix with a sync.Once or by storing the exit error after the first read:

type interactivePTYSession struct {
    // ...
    exitErr  error
    exitOnce sync.Once
}

func (s *interactivePTYSession) waitDone() error {
    s.exitOnce.Do(func() { s.exitErr = <-s.done })
    return s.exitErr
}

Then use s.waitDone() in both waitForText and close().

@copilot please address this.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions github-actions Bot 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.

Skills-Based Review 🧠

Applied /tdd — requesting changes on four correctness and reliability issues in the new PTY harness.

📋 Key Themes & Highlights

Issues

  1. Done channel drain (line 279)done is a buffered send-once channel; a second waitForText call after process exit will block forever. Use close(done) as a broadcast instead.
  2. Goroutine lifetime (line 258) — the io.Copy goroutine has no completion signal, creating a potential race between the final readAll() assertion and the goroutine's last write. Add a copyDone channel and drain it in close.
  3. Temp dir cleanup (line 396) — bare defer os.RemoveAll won't run after a require failure. Replace with t.Cleanup.
  4. Empty stub responses (line 188) — the fake gh script returns no JSON body for several API paths. This silently degrades the test rather than surfacing a real parser failure.

Positive Highlights

  • ✅ Clean separation: PTY session harness, fixture setup, and assertions are well-factored
  • ✅ Thread-safe lockedBuffer avoids data races on the output buffer
  • ✅ Good use of --no-secret and environment var overrides to isolate the wizard from the CI environment
  • ✅ Ordering assertion via index comparison is more precise than just checking presence

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 49.2 AIC · ⌖ 5.11 AIC · ⊞ 6.7K
Comment /matt to run again

}

select {
case err := <-s.done:

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.

[/tdd] The done channel is drained here but never refilled — a second call to waitForText after the process has exited will block indefinitely at the channel select instead of falling through to the timeout.

💡 Suggested fix: close the channel instead of sending

Use a closed channel as a broadcast signal so multiple waitForText calls all see the exit:

// in goroutine:
go func() {
    s.exitErr = cmd.Wait()
    close(s.done) // broadcast to all waiters
}()

// in waitForText select:
case <-s.done: // non-destructive read on closed channel

This avoids the send-once / receive-once pitfall of buffered channels.

@copilot please address this.

}

go func() {
_, _ = io.Copy(&session.output, ptmx)

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.

[/tdd] The goroutine copying PTY output has no clean shutdown path — when close(t) closes the PTY, io.Copy returns an error that's silently discarded. If the test panics or the PTY closes mid-read, the goroutine may linger and race against buffer reads.

💡 Suggested improvement

Route the copy goroutine's done signal back to the session so close can wait for it:

copyDone := make(chan struct{})
go func() {
    defer close(copyDone)
    _, _ = io.Copy(&session.output, ptmx)
}()
// store copyDone in session, wait in close()

This prevents data races in the final readAll() assertion after close().

@copilot please address this.


func TestTuistoryAddWizardManifestBootstrapRunsBeforeEngineSelection(t *testing.T) {
setup := setupAddWizardManifestTuistoryTest(t)
defer func() {

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.

[/tdd] Using defer os.RemoveAll in the test body bypasses t.Cleanup, which means the temp directories won't be cleaned up if the test panics before the deferred function runs in a goroutine context. Prefer t.Cleanup to guarantee removal regardless of test exit path.

💡 Suggested replacement
setup := setupAddWizardManifestTuistoryTest(t)
t.Cleanup(func() {
    _ = os.RemoveAll(setup.tempDir)
    _ = os.RemoveAll(setup.fakeGHDir)
})

t.Cleanup runs even on t.FailNow() / require failures.

@copilot please address this.

echo '{"permission":"write"}'
exit 0
;;
/repos/octo/example/actions/variables?per_page=100|/repos/octo/example/actions/secrets?per_page=100|/repos/octo/example/actions/secrets|/orgs/octo/actions/secrets)

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.

[/tdd] The fake gh script silently exits 0 for several API paths (/repos/.../actions/variables, secrets, etc.) without emitting any JSON — callers expecting a JSON body will get an empty response and may silently misbehave rather than failing loudly.

💡 Suggested fix

Return minimal valid JSON for each stubbed path so the wizard doesn't silently degrade:

/repos/octo/example/actions/variables?per_page=100)
  echo '{"variables":[]}'
  exit 0
  ;;
/repos/octo/example/actions/secrets?per_page=100)
  echo '{"secrets":[]}'
  exit 0
  ;;

Silent empty responses mask real parsing bugs from reaching the test assertions.

@copilot please address this.

require.NotEqual(t, -1, enginePromptIndex, "Expected to find engine selection prompt in session output")
assert.Less(t, earlyPromptIndex, enginePromptIndex, "Pre-install bootstrap prompt should appear before engine selection")

session.interrupt(t)

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.

[/tdd] The test ends with session.interrupt(t) but never verifies that the process actually exited. If the Ctrl-C is swallowed (e.g. the wizard ignores SIGINT during a prompt), the deferred session.close will wait 2 seconds then kill — silently hiding the regression.

💡 Suggested addition

After the interrupt, wait for the process to exit cleanly:

session.interrupt(t)
select {
case <-session.done:
    // clean exit
case <-time.After(5 * time.Second):
    t.Error("process did not exit after Ctrl-C")
}

This turns a cleanup timeout into an explicit test failure.

@copilot please address this.

@pelikhan
pelikhan enabled auto-merge (squash) July 23, 2026 09:39
@pelikhan
pelikhan merged commit c180c1b into main Jul 23, 2026
@pelikhan
pelikhan deleted the copilot/add-integration-tests branch July 23, 2026 09:39
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.83.1

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.

3 participants