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
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ADR-47517: PTY-Backed Integration Test Harness for Add-Wizard Manifest Bootstrap Ordering

**Date**: 2026-07-23
**Status**: Draft
**Deciders**: Unknown

---

### Context

PR #47462 changed `add-wizard` to surface `aw.yml` config actions before the generic engine setup flow, ensuring pre-install bootstrap steps (such as repo-variable prompts) are presented to users before engine selection. This ordering invariant was verified only by unit tests, which cannot simulate a real interactive terminal. Validating the terminal-boundary behavior — that prompts appear in the correct order on a real PTY — requires integration tests that exercise the full `add-wizard` binary under a live interactive session. The existing tuistory-based tests require `npx`, live `gh` authentication, and network access, making them unsuitable for isolated environments.

### Decision

We will introduce a PTY-backed integration test harness using `github.com/creack/pty` to drive the `add-wizard` binary in a real pseudo-terminal, combined with a fake `gh` CLI stub that satisfies the binary's GitHub API calls without external network access. Tests set up a temporary git repository containing a local package fixture with a known `aw.yml` manifest and assert on the order in which prompts appear in the PTY output stream.

### Alternatives Considered

#### Alternative 1: Extend the Existing Tuistory-Based Tests

The project already uses `tuistory` to run add-wizard sessions against a real interactive terminal. Extending those tests to cover the manifest bootstrap case would reuse existing infrastructure. However, tuistory tests require live `gh` authentication and `npx` availability; they cannot run in environments without external network access or a real GitHub login, making them unsuitable as the sole regression guard for this ordering invariant. This alternative was not chosen because the new test scenario must be isolatable from external state.

#### Alternative 2: Assert Ordering via Unit Tests with a Fake Terminal Writer

Unit tests could mock the `io.Writer` passed to the wizard and assert on the sequence of strings written. This avoids a real PTY and any external tooling. However, terminal prompt rendering in interactive CLI frameworks is sensitive to whether the output is an actual terminal; a fake writer changes the code path (skipping interactive prompts entirely in CI-like environments) and therefore cannot confirm that the visual ordering of prompts at the real terminal boundary is correct. This alternative was not chosen because it would not catch regressions in the actual interactive flow.

### Consequences

#### Positive
- Provides a regression guard that exercises the full binary at the terminal boundary, catching ordering bugs that unit tests cannot detect.
- Tests are self-contained: a fake `gh` stub and a temporary git repository eliminate all external dependencies, so the test suite runs in offline or restricted environments.

#### Negative
- Introduces `github.com/creack/pty` as a direct test dependency; PTY allocation is Unix-specific, so the new test cannot run on Windows.
- The PTY harness adds non-trivial test infrastructure (mutex-protected buffer, done channel, interrupt helper) that future contributors must understand and maintain.

#### Neutral
- The fake `gh` CLI stub hard-codes specific GitHub API responses; tests that exercise code paths relying on responses outside the stub's scope will emit an error and exit non-zero, requiring stub expansion as the wizard evolves.
- The test requires the compiled `add-wizard` binary to be present (via `globalBinaryPath`), linking it to the build step rather than running against library-level interfaces.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
290 changes: 290 additions & 0 deletions pkg/cli/add_wizard_tuistory_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
package cli

import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"

"github.com/creack/pty"
"github.com/github/gh-aw/pkg/fileutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -22,6 +26,13 @@ type addWizardTuistorySetup struct {
workflowPath string
}

type addWizardManifestTuistorySetup struct {
tempDir string
binaryPath string
packagePath string
fakeGHDir string
}

func setupAddWizardTuistoryTest(t *testing.T) *addWizardTuistorySetup {
t.Helper()

Expand Down Expand Up @@ -79,18 +90,243 @@ func runTuistory(t *testing.T, args ...string) (string, error) {
return string(output), err
}

func runGitCommand(t *testing.T, dir string, args ...string) {
t.Helper()

cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
require.NoError(t, err, "git %s failed: %s", strings.Join(args, " "), string(output))
}

func setupAddWizardManifestTuistoryTest(t *testing.T) *addWizardManifestTuistorySetup {
t.Helper()

tempDir, err := os.MkdirTemp("", "gh-aw-add-wizard-manifest-*")
require.NoError(t, err, "Failed to create temp directory")

runGitCommand(t, tempDir, "init")
runGitCommand(t, tempDir, "config", "user.name", "Test User")
runGitCommand(t, tempDir, "config", "user.email", "test@example.com")

packagePath := filepath.Join(tempDir, "local-package")
workflowsDir := filepath.Join(packagePath, "workflows")
require.NoError(t, os.MkdirAll(workflowsDir, 0755), "Failed to create local package directories")

manifestContent := `manifest-version: "1"
name: Local Add Wizard Package
files:
- workflows/bootstrap.md
config:
- type: repo-variable
name: OPTIONAL_BOOTSTRAP_VAR
prompt: Enter optional bootstrap variable
description: Leave blank to skip this optional setup value.
optional: true
- type: handoff
message: Post-install handoff should not appear before engine selection.
`
require.NoError(t, os.WriteFile(filepath.Join(packagePath, "aw.yml"), []byte(manifestContent), 0644), "Failed to write local package manifest")
require.NoError(t, os.WriteFile(filepath.Join(packagePath, "README.md"), []byte("# Local Add Wizard Package\n"), 0644), "Failed to write local package README")

workflowContent := `---
name: Local Package Bootstrap Integration
on:
workflow_dispatch:
engine: copilot
---

# Local Package Bootstrap Integration

This workflow is used by add-wizard manifest integration tests.
`
require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "bootstrap.md"), []byte(workflowContent), 0644), "Failed to write local package workflow")

runGitCommand(t, tempDir, "add", "local-package")
runGitCommand(t, tempDir, "commit", "-m", "Add local package fixture")

fakeGHDir, err := os.MkdirTemp("", "gh-aw-fake-gh-*")
require.NoError(t, err, "Failed to create fake gh directory")

fakeGHScript := `#!/usr/bin/env bash
set -euo pipefail

if [[ "$#" -ge 2 && "$1" == "auth" && "$2" == "status" ]]; then
echo "github.com"
exit 0
fi

if [[ "$#" -ge 2 && "$1" == "repo" && "$2" == "view" ]]; then
echo "octo/example"
exit 0
fi

if [[ "$#" -ge 2 && "$1" == "api" ]]; then
path=""
for arg in "$@"; do
case "$arg" in
/repos/*|/orgs/*|/user)
path="$arg"
break
;;
esac
done

case "$path" in
/repos/octo/example)
echo "public"
exit 0
;;
/repos/octo/example/actions/permissions)
echo '{"enabled":true,"allowed_actions":"all"}'
exit 0
;;
/repos/octo/example/collaborators/tester/permission)
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.

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.

exit 0
;;
/user)
echo "tester"
exit 0
;;
esac
fi

echo "unexpected gh invocation: $*" >&2
exit 1
`
fakeGHPath := filepath.Join(fakeGHDir, "gh")
require.NoError(t, os.WriteFile(fakeGHPath, []byte(fakeGHScript), 0755), "Failed to write fake gh script")

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

fakeGHDir: fakeGHDir,
}
}

func waitForTuistoryText(t *testing.T, sessionName string, text string, timeoutMs int) {
t.Helper()
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)
Comment on lines 214 to 218
}

type lockedBuffer struct {
mu sync.Mutex
b bytes.Buffer
}

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.

}

func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.String()
}

type interactivePTYSession struct {
cmd *exec.Cmd
ptmx *os.File
output lockedBuffer
done chan error
}

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.

require.NoError(t, err, "failed to start PTY")

session := &interactivePTYSession{
cmd: cmd,
ptmx: ptmx,
done: make(chan error, 1),
}

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.

}()

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.

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):
    }


return session
}

func (s *interactivePTYSession) readAll() string {
return s.output.String()
}

func (s *interactivePTYSession) waitForText(text string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if strings.Contains(s.readAll(), text) {
return nil
}

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.

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.

if strings.Contains(s.readAll(), text) {
return nil
}
if err == nil {
return fmt.Errorf("process exited before output contained %q\nOutput:\n%s", text, s.readAll())
}
return fmt.Errorf("process exited before output contained %q: %w\nOutput:\n%s", text, err, s.readAll())
default:
}

time.Sleep(100 * time.Millisecond)
}

return fmt.Errorf("timed out waiting for %q\nOutput:\n%s", text, s.readAll())
}

func (s *interactivePTYSession) writeString(t *testing.T, text string) {
t.Helper()
_, err := io.WriteString(s.ptmx, text)
require.NoError(t, err, "failed to write to PTY")
}

func (s *interactivePTYSession) interrupt(t *testing.T) {
t.Helper()
s.writeString(t, "\x03")
}

func (s *interactivePTYSession) close(t *testing.T) {
t.Helper()
_ = s.ptmx.Close()
select {
case <-s.done:
case <-time.After(2 * time.Second):
_ = s.cmd.Process.Kill()
}
}

func TestTuistoryAddWizardIntegration(t *testing.T) {
const launchTimeoutMs = 30000 // 30 seconds

if _, err := exec.LookPath("npx"); err != nil {
t.Skip("npx not available, skipping tuistory add-wizard integration test")
}
if _, err := exec.LookPath("gh"); err != nil {
t.Skip("gh not available, skipping tuistory add-wizard integration test")
}
authCheck := exec.Command("gh", "auth", "status")
if output, err := authCheck.CombinedOutput(); err != nil {
t.Skipf("gh auth is not usable in this environment: %v (%s)", err, string(output))
}

versionOutput, err := runTuistory(t, "--version")
if err != nil {
Expand All @@ -112,6 +348,8 @@ func TestTuistoryAddWizardIntegration(t *testing.T) {
"--cols", "140",
"--rows", "40",
"--env", "CI=",
"--env", "CONTINUOUS_INTEGRATION=",
"--env", "GITHUB_ACTIONS=",
"--env", "GO_TEST_MODE=",
"--timeout", fmt.Sprintf("%d", launchTimeoutMs),
}
Expand Down Expand Up @@ -152,3 +390,55 @@ func TestTuistoryAddWizardIntegration(t *testing.T) {
_, statErr := os.Stat(addedWorkflowPath)
assert.ErrorIs(t, statErr, os.ErrNotExist, "Workflow file should not be created when add-wizard is cancelled")
}

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.

_ = os.RemoveAll(setup.tempDir)
_ = os.RemoveAll(setup.fakeGHDir)
}()

const earlyPrompt = "Enter optional bootstrap variable"
const enginePrompt = "Which coding agent would you like to use?"
const lateMessage = "Post-install handoff should not appear before engine selection."

cmd := exec.Command(setup.binaryPath, "add-wizard", "./local-package", "--no-secret")
cmd.Dir = setup.tempDir
cmd.Env = append(os.Environ(),
"CI=",
"CONTINUOUS_INTEGRATION=",
"GITHUB_ACTIONS=",
"GO_TEST_MODE=",
"NO_COLOR=1",
"PAGER=cat",
"GH_PAGER=cat",
fmt.Sprintf("PATH=%s%c%s", setup.fakeGHDir, os.PathListSeparator, os.Getenv("PATH")),
)

session := startInteractivePTYSession(t, cmd)
defer session.close(t)

require.NoError(t, session.waitForText(earlyPrompt, 30*time.Second), "Expected pre-install bootstrap prompt")

beforeEngineOutput := session.readAll()
assert.Contains(t, beforeEngineOutput, earlyPrompt, "Expected pre-install bootstrap prompt to be shown")
assert.NotContains(t, beforeEngineOutput, enginePrompt, "Engine selection should not start before pre-install bootstrap setup")
assert.NotContains(t, beforeEngineOutput, lateMessage, "Post-install bootstrap steps should not run before engine selection")

session.writeString(t, "\r")

require.NoError(t, session.waitForText(enginePrompt, 30*time.Second), "Expected engine selection prompt")

afterEngineOutput := session.readAll()
assert.Contains(t, afterEngineOutput, earlyPrompt, "Expected pre-install bootstrap prompt in final session output")
assert.Contains(t, afterEngineOutput, enginePrompt, "Expected engine selection prompt after pre-install bootstrap setup")
assert.NotContains(t, afterEngineOutput, lateMessage, "Post-install bootstrap steps should not run before installation")

earlyPromptIndex := strings.Index(afterEngineOutput, earlyPrompt)
enginePromptIndex := strings.Index(afterEngineOutput, enginePrompt)
require.NotEqual(t, -1, earlyPromptIndex, "Expected to find pre-install bootstrap prompt in session output")
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.

}