diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d20ae0c..1fab8d8d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,18 +8,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: 1.24.x - - name: setup-env - run: | - sudo apt remove bpfcc-tools - bash --version - uname -a - compgen -ac | sort | grep '^ex' - - - run: make tests_excluding_ash + + # TODO: Generalize using docker container across all testers + # Right now, we just use the docker test for recording fixtures and testing manually in this repo + - name: Run tests (Docker) + run: ./docker_test/docker_test.sh tests_excluding_ash lint: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 490c4aec..0a157233 100644 --- a/Makefile +++ b/Makefile @@ -174,7 +174,13 @@ endef define _BACKGROUND_JOBS_STAGES [ \ {"slug":"af3","tester_log_prefix":"tester::#af3","title":"Stage#1: The jobs builtin"}, \ - {"slug":"at7","tester_log_prefix":"tester::#at7","title":"Stage#2: Background launch"} \ + {"slug":"at7","tester_log_prefix":"tester::#at7","title":"Stage#2: Starting background jobs"}, \ + {"slug":"jd6","tester_log_prefix":"tester::#jd6","title":"Stage#3: List a single job"}, \ + {"slug":"dk5","tester_log_prefix":"tester::#dk5","title":"Stage#4: List multiple jobs"}, \ + {"slug":"ma9","tester_log_prefix":"tester::#ma9","title":"Stage#5: Reaping one job using jobs"}, \ + {"slug":"rq2","tester_log_prefix":"tester::#rq2","title":"Stage#6: Reaping multiple jobs using jobs"}, \ + {"slug":"bv8","tester_log_prefix":"tester::#bv8","title":"Stage#7: Reap before the next prompt"}, \ + {"slug":"fy4","tester_log_prefix":"tester::#fy4","title":"Stage#8: Job number reset"} \ ] endef diff --git a/go.mod b/go.mod index 11c6b9ce..a135cc52 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/x/vt v0.0.0-20250122132629-a969ddeb820d github.com/codecrafters-io/tester-utils v0.4.15 github.com/creack/pty v1.1.24 + github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.18.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/shirou/gopsutil/v3 v3.24.5 @@ -19,6 +20,7 @@ require ( // replace github.com/codecrafters-io/tester-utils v0.2.22 => /Users/rohitpaulk/experiments/codecrafters/tester-utils require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect github.com/charmbracelet/x/ansi v0.7.0 // indirect github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 82f1cce9..8e715dc5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/charmbracelet/x/ansi v0.7.0 h1:/QfFmiXOGGwN6fRbzvQaYp7fu1pkxpZ3qFBZWBsP404= github.com/charmbracelet/x/ansi v0.7.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/vt v0.0.0-20250122132629-a969ddeb820d h1:cIxw4y+aEGuxgofJdiZnD+wyNvD03k0ViFsSOBiyGiU= @@ -11,6 +13,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= diff --git a/internal/stage_bg1.go b/internal/stage_bg1.go index 19032e3b..f9928233 100644 --- a/internal/stage_bg1.go +++ b/internal/stage_bg1.go @@ -16,20 +16,18 @@ func testBG1(stageHarness *test_case_harness.TestCaseHarness) error { return err } - typeTestCase := test_cases.CommandResponseTestCase{ - Command: "type jobs", - ExpectedOutput: "jobs is a shell builtin", - SuccessMessage: "Expected type for 'jobs' found", + typeTestCase := test_cases.TypeOfCommandTestCase{ + Command: "jobs", } - if err := typeTestCase.Run(asserter, shell, logger); err != nil { + if err := typeTestCase.RunForBuiltin(asserter, shell, logger); err != nil { return err } jobsTestCase := test_cases.JobsBuiltinResponseTestCase{ SuccessMessage: "✓ Received empty response", // Expect no output - ExpectedOutputItems: []test_cases.JobsBuiltinOutputEntry{}, + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{}, } if err := jobsTestCase.Run(asserter, shell, logger); err != nil { diff --git a/internal/stage_bg3.go b/internal/stage_bg3.go new file mode 100644 index 00000000..d0becd93 --- /dev/null +++ b/internal/stage_bg3.go @@ -0,0 +1,47 @@ +package internal + +import ( + "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" + "github.com/codecrafters-io/shell-tester/internal/shell_executable" + "github.com/codecrafters-io/shell-tester/internal/test_cases" + "github.com/codecrafters-io/tester-utils/test_case_harness" +) + +func testBG3(stageHarness *test_case_harness.TestCaseHarness) error { + logger := stageHarness.Logger + shell := shell_executable.NewShellExecutable(stageHarness) + asserter := logged_shell_asserter.NewLoggedShellAsserter(shell) + + if err := asserter.StartShellAndAssertPrompt(true); err != nil { + return err + } + + // Launch a program in the background + backgroundLaunchCommand := "sleep 100" + backgroundLaunchTestCase := test_cases.BackgroundCommandResponseTestCase{ + Command: backgroundLaunchCommand, + SuccessMessage: "✓ Output includes job number with PID", + ExpectedJobNumber: 1, + } + + if err := backgroundLaunchTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Assert the job output + jobsTestCase := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{{ + JobNumber: 1, + Status: "Running", + LaunchCommand: backgroundLaunchCommand, + Marker: test_cases.CurrentJob, + }}, + SuccessMessage: "✓ 1 entry matches the running job", + } + + if err := jobsTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + return logAndQuit(asserter, nil) +} diff --git a/internal/stage_bg4.go b/internal/stage_bg4.go new file mode 100644 index 00000000..6b1ea4be --- /dev/null +++ b/internal/stage_bg4.go @@ -0,0 +1,91 @@ +package internal + +import ( + "fmt" + + "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" + "github.com/codecrafters-io/shell-tester/internal/shell_executable" + "github.com/codecrafters-io/shell-tester/internal/test_cases" + "github.com/codecrafters-io/tester-utils/logger" + "github.com/codecrafters-io/tester-utils/test_case_harness" + "github.com/dustin/go-humanize/english" +) + +func testBG4(stageHarness *test_case_harness.TestCaseHarness) error { + logger := stageHarness.Logger + shell := shell_executable.NewShellExecutable(stageHarness) + asserter := logged_shell_asserter.NewLoggedShellAsserter(shell) + + if err := asserter.StartShellAndAssertPrompt(true); err != nil { + return err + } + + commands := []string{"sleep 100", "sleep 200", "sleep 300"} + + if err := launchBgCommandAndAssertJobs(asserter, shell, logger, commands); err != nil { + return err + } + + return logAndQuit(asserter, nil) +} + +// launchBgCommandAndAssertJobs launches the given bgCommands one by one +// with a 'jobs' call after each launch +func launchBgCommandAndAssertJobs(asserter *logged_shell_asserter.LoggedShellAsserter, shell *shell_executable.ShellExecutable, logger *logger.Logger, bgCommands []string) error { + type jobInfo struct { + JobNumber int + Command string + } + + var jobs []jobInfo + + for i, bgCommand := range bgCommands { + backgroundLaunchTestCase := test_cases.BackgroundCommandResponseTestCase{ + Command: bgCommand, + SuccessMessage: "✓ Output includes job number with PID", + ExpectedJobNumber: i + 1, + } + + if err := backgroundLaunchTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + jobs = append(jobs, jobInfo{JobNumber: i + 1, Command: bgCommand}) + + jobsOutputEntries := make([]test_cases.BackgroundJobStatusEntry, 0, len(jobs)) + + for i, job := range jobs { + // Default marker is unmarked + marker := test_cases.UnmarkedJob + + // If the job was recently launched, it is the 'Current' job + if i == len(jobs)-1 { + marker = test_cases.CurrentJob + // If the job was launched previously, it is the 'Previous' job + } else if i == len(jobs)-2 { + marker = test_cases.PreviousJob + } + + jobsOutputEntries = append(jobsOutputEntries, test_cases.BackgroundJobStatusEntry{ + JobNumber: job.JobNumber, + Status: "Running", + LaunchCommand: job.Command, + Marker: marker, + }) + } + + jobsTestCase := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: jobsOutputEntries, + SuccessMessage: fmt.Sprintf( + "✓ %s match the running %s", + english.Plural(len(jobsOutputEntries), "entry", "entries"), + english.PluralWord(len(jobsOutputEntries), "job", "jobs"), + ), + } + + if err := jobsTestCase.Run(asserter, shell, logger); err != nil { + return err + } + } + return nil +} diff --git a/internal/stage_bg5.go b/internal/stage_bg5.go new file mode 100644 index 00000000..bbb07182 --- /dev/null +++ b/internal/stage_bg5.go @@ -0,0 +1,91 @@ +package internal + +import ( + "fmt" + "time" + + "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" + "github.com/codecrafters-io/shell-tester/internal/shell_executable" + "github.com/codecrafters-io/shell-tester/internal/test_cases" + "github.com/codecrafters-io/tester-utils/random" + "github.com/codecrafters-io/tester-utils/test_case_harness" +) + +func testBG5(stageHarness *test_case_harness.TestCaseHarness) error { + logger := stageHarness.Logger + shell := shell_executable.NewShellExecutable(stageHarness) + asserter := logged_shell_asserter.NewLoggedShellAsserter(shell) + + if err := asserter.StartShellAndAssertPrompt(true); err != nil { + return err + } + + // Launch a grep fifo + fifoPath := fmt.Sprintf("/tmp/%s-%d", random.RandomWord(), random.RandomInt(1, 100)) + if err := CreateRandomFIFOWithTeardown(stageHarness, fifoPath, 0644); err != nil { + return err + } + + grepPattern := random.RandomWord() + bgGrepCommand := fmt.Sprintf("grep %s %s", grepPattern, fifoPath) + + bgJobTestCase := test_cases.BackgroundCommandResponseTestCase{ + Command: bgGrepCommand, + ExpectedJobNumber: 1, + SuccessMessage: "✓ Output includes job number with PID", + } + + if err := bgJobTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Call jobs + jobsBuiltinTestCase1 := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{{ + JobNumber: 1, + Status: "Running", + LaunchCommand: bgGrepCommand, + Marker: test_cases.CurrentJob, + }}, + SuccessMessage: "✓ 1 entry matches the running job", + } + + if err := jobsBuiltinTestCase1.Run(asserter, shell, logger); err != nil { + return err + } + + // Write to fifo + if err := WriteToFile(stageHarness, fifoPath, grepPattern); err != nil { + return err + } + + // A small delay since grep takes some time to process and exit + time.Sleep(time.Millisecond) + + err := test_cases.BackgroundCommandOutputOnlyTestCase{ + ExpectedOutputLines: []string{grepPattern}, + SuccessMessage: "✓ Output of background command 'grep' found", + }.Run(asserter, shell, logger) + + if err != nil { + return err + } + + // Call jobs again + jobsBuiltinTestCase2 := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{{ + JobNumber: 1, + Status: "Done", + LaunchCommand: bgGrepCommand, + Marker: test_cases.CurrentJob, + }}, + ShouldSkipCurrentPromptAssertion: true, + SuccessMessage: "✓ 1 entry matches the finished job", + } + + if err := jobsBuiltinTestCase2.Run(asserter, shell, logger); err != nil { + return err + } + + return logAndQuit(asserter, nil) +} diff --git a/internal/stage_bg6.go b/internal/stage_bg6.go new file mode 100644 index 00000000..22f88111 --- /dev/null +++ b/internal/stage_bg6.go @@ -0,0 +1,148 @@ +package internal + +import ( + "fmt" + "time" + + "al.essio.dev/pkg/shellescape" + "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" + "github.com/codecrafters-io/shell-tester/internal/shell_executable" + "github.com/codecrafters-io/shell-tester/internal/test_cases" + "github.com/codecrafters-io/tester-utils/random" + "github.com/codecrafters-io/tester-utils/test_case_harness" +) + +func testBG6(stageHarness *test_case_harness.TestCaseHarness) error { + logger := stageHarness.Logger + shell := shell_executable.NewShellExecutable(stageHarness) + asserter := logged_shell_asserter.NewLoggedShellAsserter(shell) + + fifoBaseNames := random.RandomWords(2) + fifoPath1 := fmt.Sprintf("/tmp/%s-%d", fifoBaseNames[0], random.RandomInt(1, 100)) + if err := CreateRandomFIFOWithTeardown(stageHarness, fifoPath1, 0644); err != nil { + return err + } + + fifoPath2 := fmt.Sprintf("/tmp/%s-%d", fifoBaseNames[1], random.RandomInt(1, 100)) + if err := CreateRandomFIFOWithTeardown(stageHarness, fifoPath2, 0644); err != nil { + return err + } + + if err := asserter.StartShellAndAssertPrompt(true); err != nil { + return err + } + + // Launch "sleep 500" + sleepCommand := "sleep 500" + bgSleepTestCase := test_cases.BackgroundCommandResponseTestCase{ + Command: sleepCommand, + ExpectedJobNumber: 1, + SuccessMessage: "✓ Output includes job number with PID", + } + if err := bgSleepTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Launch 'grep read pattern' to hang the process indefinitely + + grepPattern1 := random.RandomWord() + bgGrepCommand1 := fmt.Sprintf("grep %s %s", grepPattern1, fifoPath1) + bgGrepTestCase1 := test_cases.BackgroundCommandResponseTestCase{ + Command: bgGrepCommand1, + ExpectedJobNumber: 2, + SuccessMessage: "✓ Output includes job number with PID", + } + if err := bgGrepTestCase1.Run(asserter, shell, logger); err != nil { + return err + } + + // Launch grep read pattern again + grepPattern2 := random.RandomWord() + bgGrepCommand2 := fmt.Sprintf("grep %s %s", grepPattern2, fifoPath2) + bgGrepTestCase2 := test_cases.BackgroundCommandResponseTestCase{ + Command: bgGrepCommand2, + ExpectedJobNumber: 3, + SuccessMessage: "✓ Output includes job number with PID", + } + + if err := bgGrepTestCase2.Run(asserter, shell, logger); err != nil { + return err + } + + // We write to the first fifo + if err := WriteToFile(stageHarness, fifoPath1, grepPattern1); err != nil { + return err + } + + time.Sleep(time.Millisecond) + + // Assert the background command's output + err := test_cases.BackgroundCommandOutputOnlyTestCase{ + ExpectedOutputLines: []string{grepPattern1}, + SuccessMessage: fmt.Sprintf("✓ Output of %s found", shellescape.Quote(bgGrepCommand1)), + }.Run(asserter, shell, logger) + + if err != nil { + return err + } + + // Call jobs for the first time + jobsBuiltinTestCase1 := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{ + {JobNumber: 1, Status: "Running", LaunchCommand: sleepCommand, Marker: test_cases.UnmarkedJob}, + {JobNumber: 2, Status: "Done", LaunchCommand: bgGrepCommand1, Marker: test_cases.PreviousJob}, + {JobNumber: 3, Status: "Running", LaunchCommand: bgGrepCommand2, Marker: test_cases.CurrentJob}, + }, + // Because background command will have consumed the prompt line + ShouldSkipCurrentPromptAssertion: true, + SuccessMessage: "✓ Received 3 entries in the output", + } + + if err := jobsBuiltinTestCase1.Run(asserter, shell, logger); err != nil { + return err + } + + // Write to the second fifo + if err := WriteToFile(stageHarness, fifoPath2, grepPattern2); err != nil { + return err + } + + time.Sleep(time.Millisecond) + + // Assert the background command's output + err = test_cases.BackgroundCommandOutputOnlyTestCase{ + ExpectedOutputLines: []string{grepPattern2}, + SuccessMessage: fmt.Sprintf("✓ Output of %s found", shellescape.Quote(bgGrepCommand2)), + }.Run(asserter, shell, logger) + + if err != nil { + return err + } + + // Call jobs for the second time + jobsBuiltinTestCase2 := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{ + {JobNumber: 1, Status: "Running", LaunchCommand: sleepCommand, Marker: test_cases.PreviousJob}, + {JobNumber: 3, Status: "Done", LaunchCommand: bgGrepCommand2, Marker: test_cases.CurrentJob}, + }, + // Prompt will have been consumed by the background command output + ShouldSkipCurrentPromptAssertion: true, + SuccessMessage: "✓ Received 2 entries in the output", + } + if err := jobsBuiltinTestCase2.Run(asserter, shell, logger); err != nil { + return err + } + + // Call jobs again + jobsBuiltinTestCase3 := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{ + {JobNumber: 1, Status: "Running", LaunchCommand: sleepCommand, Marker: test_cases.CurrentJob}, + }, + SuccessMessage: "✓ 1 entry matches the running job", + } + if err := jobsBuiltinTestCase3.Run(asserter, shell, logger); err != nil { + return err + } + + return logAndQuit(asserter, nil) +} diff --git a/internal/stage_bg7.go b/internal/stage_bg7.go new file mode 100644 index 00000000..80f270e1 --- /dev/null +++ b/internal/stage_bg7.go @@ -0,0 +1,112 @@ +package internal + +import ( + "fmt" + + "al.essio.dev/pkg/shellescape" + "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" + "github.com/codecrafters-io/shell-tester/internal/shell_executable" + "github.com/codecrafters-io/shell-tester/internal/test_cases" + "github.com/codecrafters-io/tester-utils/random" + "github.com/codecrafters-io/tester-utils/test_case_harness" +) + +func testBG7(stageHarness *test_case_harness.TestCaseHarness) error { + logger := stageHarness.Logger + shell := shell_executable.NewShellExecutable(stageHarness) + asserter := logged_shell_asserter.NewLoggedShellAsserter(shell) + + fifoPath := fmt.Sprintf("/tmp/%s-%d", random.RandomWord(), random.RandomInt(1, 100)) + if err := CreateRandomFIFOWithTeardown(stageHarness, fifoPath, 0644); err != nil { + return err + } + + if err := asserter.StartShellAndAssertPrompt(true); err != nil { + return err + } + + // Spawn background process: sleep 500 + sleepCommand := "sleep 500" + bgSleepTestCase := test_cases.BackgroundCommandResponseTestCase{ + Command: sleepCommand, + ExpectedJobNumber: 1, + SuccessMessage: "✓ Output includes job number with PID", + } + if err := bgSleepTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Grep read pattern + grepPattern := random.RandomWord() + bgGrepCommand := fmt.Sprintf("grep %s %s", grepPattern, fifoPath) + bgGrepTestCase := test_cases.BackgroundCommandResponseTestCase{ + Command: bgGrepCommand, + ExpectedJobNumber: 2, + SuccessMessage: "✓ Output includes job number with PID", + } + if err := bgGrepTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Run jobs + jobsBuiltinTestCase := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{ + {JobNumber: 1, Status: "Running", LaunchCommand: sleepCommand, Marker: test_cases.PreviousJob}, + {JobNumber: 2, Status: "Running", LaunchCommand: bgGrepCommand, Marker: test_cases.CurrentJob}, + }, + SuccessMessage: "✓ Found 2 entries for the running jobs", + } + if err := jobsBuiltinTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Write to fifo + if err := WriteToFile(stageHarness, fifoPath, grepPattern); err != nil { + return err + } + + // Background output test case + backgroundOutputTestCase := test_cases.BackgroundCommandOutputOnlyTestCase{ + ExpectedOutputLines: []string{grepPattern}, + SuccessMessage: fmt.Sprintf("✓ Output of %s found", shellescape.Quote(bgGrepCommand)), + } + + if err := backgroundOutputTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Issue an echo command and expect the reaped job entry will follow the echoed text + echoArgument := random.RandomWord() + echoTestCase := test_cases.CommandResponseWithReapedJobsTestCase{ + Command: fmt.Sprintf("echo %s", echoArgument), + ExpectedCommandOutput: echoArgument, + ShouldSkipCurrentPromptAssertion: true, + ExpectedReapedJobEntries: []*test_cases.BackgroundJobStatusEntry{{ + JobNumber: 2, + Status: "Done", + LaunchCommand: bgGrepCommand, + Marker: test_cases.CurrentJob, + }}, + SuccessMessage: "✓ Found command output followed by an entry for the reaped job", + } + + if err := echoTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Call jobs — only sleep (job 1) remains + jobsBuiltinTestCase2 := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{{ + JobNumber: 1, + Status: "Running", + LaunchCommand: sleepCommand, + Marker: test_cases.CurrentJob, + }}, + SuccessMessage: "✓ 1 entry matches the running job", + } + if err := jobsBuiltinTestCase2.Run(asserter, shell, logger); err != nil { + return err + } + + return logAndQuit(asserter, nil) +} diff --git a/internal/stage_bg8.go b/internal/stage_bg8.go new file mode 100644 index 00000000..41c2c030 --- /dev/null +++ b/internal/stage_bg8.go @@ -0,0 +1,213 @@ +package internal + +import ( + "fmt" + "time" + + "al.essio.dev/pkg/shellescape" + "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" + "github.com/codecrafters-io/shell-tester/internal/shell_executable" + "github.com/codecrafters-io/shell-tester/internal/test_cases" + "github.com/codecrafters-io/tester-utils/random" + "github.com/codecrafters-io/tester-utils/test_case_harness" +) + +func testBG8(stageHarness *test_case_harness.TestCaseHarness) error { + logger := stageHarness.Logger + + if err := testBg8ResetToZero(stageHarness); err != nil { + return err + } + + logger.Infof("Tearing down shell") + + return testBg8Recycle(stageHarness) +} + +func testBg8ResetToZero(stageHarness *test_case_harness.TestCaseHarness) error { + shell := shell_executable.NewShellExecutable(stageHarness) + asserter := logged_shell_asserter.NewLoggedShellAsserter(shell) + logger := stageHarness.Logger + + fifoPath1 := fmt.Sprintf("/tmp/%s-%d", random.RandomWord(), random.RandomInt(1, 100)) + if err := CreateRandomFIFOWithTeardown(stageHarness, fifoPath1, 0644); err != nil { + return err + } + + if err := asserter.StartShellAndAssertPrompt(true); err != nil { + return err + } + + grepPattern1 := random.RandomWord() + bgGrepCommand1 := fmt.Sprintf("grep %s %s", grepPattern1, fifoPath1) + bgGrepTestCase := &test_cases.BackgroundCommandResponseTestCase{ + Command: bgGrepCommand1, + ExpectedJobNumber: 1, + SuccessMessage: "✓ Output includes job number with PID", + } + if err := bgGrepTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Write to fifo to make job 1 'Done' + if err := WriteToFile(stageHarness, fifoPath1, grepPattern1); err != nil { + return err + } + + time.Sleep(time.Millisecond) + + // Background output test case for grep + backgroundOutputTestCase := test_cases.BackgroundCommandOutputOnlyTestCase{ + ExpectedOutputLines: []string{grepPattern1}, + SuccessMessage: fmt.Sprintf("✓ Output of %s found", shellescape.Quote(bgGrepCommand1)), + } + + if err := backgroundOutputTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + echoArgs := random.RandomWords(2) + echoTestCase := test_cases.CommandResponseWithReapedJobsTestCase{ + Command: fmt.Sprintf("echo %s", echoArgs[0]), + ExpectedCommandOutput: echoArgs[0], + ExpectedReapedJobEntries: []*test_cases.BackgroundJobStatusEntry{ + {JobNumber: 1, Status: "Done", LaunchCommand: bgGrepCommand1, Marker: test_cases.CurrentJob}, + }, + ShouldSkipCurrentPromptAssertion: true, + SuccessMessage: "✓ Received output for echo followed by an entry for the reaped job", + } + + if err := echoTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + // Test command output not followed by reaped jobs entry + echoTestCase2 := test_cases.CommandResponseTestCase{ + Command: fmt.Sprintf("echo %s", echoArgs[1]), + ExpectedOutput: echoArgs[1], + SuccessMessage: "✓ Received output for echo", + } + + if err := echoTestCase2.Run(asserter, shell, logger); err != nil { + return err + } + + jobsEmptyTestCase := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{}, + SuccessMessage: "✓ No jobs", + } + + if err := jobsEmptyTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + sleepCommand := "sleep 10" + if err := (&test_cases.BackgroundCommandResponseTestCase{ + Command: sleepCommand, + ExpectedJobNumber: 1, + SuccessMessage: "✓ Output includes job number with PID", + }).Run(asserter, shell, logger); err != nil { + return err + } + + jobsOneEntryTestCase := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{{ + JobNumber: 1, Status: "Running", LaunchCommand: sleepCommand, Marker: test_cases.CurrentJob, + }}, + SuccessMessage: "✓ 1 entry matches the running job", + } + + if err := jobsOneEntryTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + return nil +} + +func testBg8Recycle(stageHarness *test_case_harness.TestCaseHarness) error { + shell := shell_executable.NewShellExecutable(stageHarness) + asserter := logged_shell_asserter.NewLoggedShellAsserter(shell) + logger := stageHarness.Logger + + fifoPath := fmt.Sprintf("/tmp/%s-%d", random.RandomWord(), random.RandomInt(1, 100)) + if err := CreateRandomFIFOWithTeardown(stageHarness, fifoPath, 0644); err != nil { + return err + } + + if err := asserter.StartShellAndAssertPrompt(true); err != nil { + return err + } + + sleepCommand := "sleep 100" + sleepCommandTestCase := &test_cases.BackgroundCommandResponseTestCase{ + Command: sleepCommand, + ExpectedJobNumber: 1, + SuccessMessage: "✓ Output includes job number with PID", + } + if err := sleepCommandTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + grepPattern := random.RandomWord() + bgGrepCommand := fmt.Sprintf("grep %s %s", grepPattern, fifoPath) + if err := (&test_cases.BackgroundCommandResponseTestCase{ + Command: bgGrepCommand, + ExpectedJobNumber: 2, + SuccessMessage: "✓ Output includes job number with PID", + }).Run(asserter, shell, logger); err != nil { + return err + } + + if err := WriteToFile(stageHarness, fifoPath, grepPattern); err != nil { + return err + } + time.Sleep(time.Millisecond) + + // Background output test case for grep + + backgroundOutputTestCase := test_cases.BackgroundCommandOutputOnlyTestCase{ + ExpectedOutputLines: []string{grepPattern}, + SuccessMessage: fmt.Sprintf("✓ Output of %s found", shellescape.Quote(bgGrepCommand)), + } + + if err := backgroundOutputTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + echoArgument := random.RandomWord() + echoTestCase := test_cases.CommandResponseWithReapedJobsTestCase{ + Command: fmt.Sprintf("echo %s", echoArgument), + ExpectedCommandOutput: echoArgument, + ExpectedReapedJobEntries: []*test_cases.BackgroundJobStatusEntry{{ + JobNumber: 2, Status: "Done", LaunchCommand: bgGrepCommand, Marker: test_cases.CurrentJob, + }}, + ShouldSkipCurrentPromptAssertion: true, + SuccessMessage: "✓ Received output for echo followed by an entry for the reaped job", + } + if err := echoTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + sleepCommand2 := "sleep 50" + if err := (&test_cases.BackgroundCommandResponseTestCase{ + Command: sleepCommand2, + ExpectedJobNumber: 2, + SuccessMessage: "✓ Output includes job number with PID", + }).Run(asserter, shell, logger); err != nil { + return err + } + + jobsTestCase := test_cases.JobsBuiltinResponseTestCase{ + ExpectedOutputEntries: []test_cases.BackgroundJobStatusEntry{ + {JobNumber: 1, Status: "Running", LaunchCommand: sleepCommand, Marker: test_cases.PreviousJob}, + {JobNumber: 2, Status: "Running", LaunchCommand: sleepCommand2, Marker: test_cases.CurrentJob}, + }, + SuccessMessage: "✓ 2 entries match the running jobs", + } + + if err := jobsTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + return logAndQuit(asserter, nil) +} diff --git a/internal/stage_p1.go b/internal/stage_p1.go index 130d3d02..bc416d28 100644 --- a/internal/stage_p1.go +++ b/internal/stage_p1.go @@ -78,10 +78,10 @@ func testP1(stageHarness *test_case_harness.TestCaseHarness) error { input = fmt.Sprintf(`tail -f %s | head -n 5`, filePath) expectedMultiLineOutput := strings.Split(strings.Trim(fileContent, "\n"), "\n") multiLineTestCase := test_cases.CommandWithMultilineResponseTestCase{ - Command: input, - MultiLineAssertion: assertions.NewMultiLineAssertion(expectedMultiLineOutput), - SuccessMessage: "✓ Received redirected file content", - SkipPromptAssertion: true, + Command: input, + MultiLineAssertion: assertions.NewMultiLineAssertion(expectedMultiLineOutput), + SuccessMessage: "✓ Received redirected file content", + ShouldSkipNextPromptAssertion: true, } if err := multiLineTestCase.Run(asserter, shell, logger); err != nil { return err diff --git a/internal/stages_test.go b/internal/stages_test.go index 5cfa35fc..4341fd49 100644 --- a/internal/stages_test.go +++ b/internal/stages_test.go @@ -98,7 +98,7 @@ func TestStages(t *testing.T) { NormalizeOutputFunc: normalizeTesterOutput, }, "background_jobs_pass_bash": { - StageSlugs: []string{"af3", "at7"}, + StageSlugs: []string{"af3", "at7", "jd6", "dk5", "ma9", "rq2", "bv8", "fy4"}, CodePath: "./test_helpers/bash", ExpectedExitCode: 0, StdoutFixturePath: "./test_helpers/fixtures/bash/background_jobs/pass", @@ -118,6 +118,34 @@ func TestStages(t *testing.T) { StdoutFixturePath: "./test_helpers/fixtures/background_jobs_incorrect_job_number", NormalizeOutputFunc: normalizeTesterOutput, }, + "background_jobs_jobs_builtin_incorrect_marker": { + StageSlugs: []string{"dk5"}, + CodePath: "./test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/background_jobs_jobs_builtin_incorrect_marker", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "background_jobs_jobs_builtin_incorrect_output_format": { + StageSlugs: []string{"dk5"}, + CodePath: "./test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/background_jobs_jobs_builtin_incorrect_output_format", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "background_jobs_jobs_builtin_not_reaped": { + StageSlugs: []string{"rq2"}, + CodePath: "./test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/background_jobs_jobs_builtin_not_reaped", + NormalizeOutputFunc: normalizeTesterOutput, + }, + "background_jobs_job_number_not_recycled": { + StageSlugs: []string{"fy4"}, + CodePath: "./test_helpers/scenarios/background_jobs_job_number_not_recycled", + ExpectedExitCode: 1, + StdoutFixturePath: "./test_helpers/fixtures/background_jobs_job_number_not_recycled", + NormalizeOutputFunc: normalizeTesterOutput, + }, "background_jobs_incorrect_pid": { StageSlugs: []string{"at7"}, CodePath: "./test_helpers/scenarios/background_jobs_incorrect_pid", @@ -232,12 +260,14 @@ func normalizeTesterOutput(testerOutput []byte) []byte { "ls-la-output-line": {regexp.MustCompile(`-rw-r--r-- .*`)}, "PATH is now: ": {regexp.MustCompile(`PATH is now: .*`)}, "/tmp/": {regexp.MustCompile(`/var/folders/.*/.*/.*/`)}, - "[your-program] [JOB_NUM] PID": {regexp.MustCompile(`\[your-program\].*\[\d+\] \d+`)}, - // For intentional error cases fixtures in background-jobs extension - "[your-program] [JOB_NUM]PID": {regexp.MustCompile(`\[your-program\].*\[\d+\]\d+`)}, - "[tester::#AT7] Received: \"[JOB_NUM] PID\"": {regexp.MustCompile(`\[tester::#AT7\].*Received:.*"\[\d+\] \d+"`)}, - "[tester::#AT7] ✓ Found process with PID ": {regexp.MustCompile(`\[tester::#AT7\].*Found process with PID \d+`)}, - "[tester::#AT7] Received: \"[JOB_NUM]PID\"": {regexp.MustCompile(`\[tester::#AT7\].*Received:.*"\[\d+\]\d+"`)}, + // Background jobs pass cases + "[your-program] [JOB_NUM] PID": {regexp.MustCompile(`\[your-program\].*\[\d+\] \d+`)}, + "[tester::#STAGE] ✓ Found process with PID ": {regexp.MustCompile(`\[tester::#[A-Z]{2}\d+\].*Found process with PID \d+`)}, + // For background_jobs error cases + "[your-program] [JOB_NUM]PID": {regexp.MustCompile(`\[your-program\].*\[\d+\]\d+`)}, + "[tester::#AT7] Received: \"[JOB_NUM]PID\"": {regexp.MustCompile(`\[tester::#AT7\].*Received:.*"\[\d+\]\d+"`)}, + "[tester::#AT7] Received: \"[JOB_NUM] PID\"": {regexp.MustCompile(`\[tester::#AT7\].*Received:.*"\[\d+\] \d+"`)}, + "[tester::#FY4] Received: \"[JOB_NUM] PID\"": {regexp.MustCompile(`\[tester::#FY4\].*Received:.*"\[\d+\] \d+"`)}, } for replacement, regexes := range replacements { diff --git a/internal/test_cases/background_command_output_only_test_case.go b/internal/test_cases/background_command_output_only_test_case.go new file mode 100644 index 00000000..fea8e806 --- /dev/null +++ b/internal/test_cases/background_command_output_only_test_case.go @@ -0,0 +1,41 @@ +package test_cases + +import ( + "fmt" + + "github.com/codecrafters-io/shell-tester/internal/assertions" + "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" + "github.com/codecrafters-io/shell-tester/internal/shell_executable" + "github.com/codecrafters-io/tester-utils/logger" +) + +type BackgroundCommandOutputOnlyTestCase struct { + ExpectedOutputLines []string + SuccessMessage string +} + +func (t BackgroundCommandOutputOnlyTestCase) Run(asserter *logged_shell_asserter.LoggedShellAsserter, shell *shell_executable.ShellExecutable, logger *logger.Logger) error { + if len(t.ExpectedOutputLines) == 0 { + panic("Codecrafters Internal Error - ExpectedOutputLines is empty in BackgroundCommandOutputOnlyTestCase") + } + + outputInPromptLine := t.ExpectedOutputLines[0] + + promptLineReflection := fmt.Sprintf("$ %s", outputInPromptLine) + asserter.AddAssertion(assertions.SingleLineAssertion{ + ExpectedOutput: promptLineReflection, + }) + + remainingOutputLinesAssertion := assertions.NewMultiLineAssertion(t.ExpectedOutputLines[1:]) + asserter.AddAssertion(&remainingOutputLinesAssertion) + + if err := asserter.AssertWithoutPrompt(); err != nil { + return err + } + + if t.SuccessMessage != "" { + logger.Successf("%s", t.SuccessMessage) + } + + return nil +} diff --git a/internal/test_cases/command_response_with_reaped_jobs_test_case.go b/internal/test_cases/command_response_with_reaped_jobs_test_case.go new file mode 100644 index 00000000..af681a98 --- /dev/null +++ b/internal/test_cases/command_response_with_reaped_jobs_test_case.go @@ -0,0 +1,70 @@ +package test_cases + +import ( + "regexp" + "time" + + "github.com/codecrafters-io/shell-tester/internal/assertions" + "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" + "github.com/codecrafters-io/shell-tester/internal/shell_executable" + "github.com/codecrafters-io/tester-utils/logger" +) + +type CommandResponseWithReapedJobsTestCase struct { + // Command is the command to send to the shell + Command string + + // ExpectedCommandOutput is the expected output string to match against + ExpectedCommandOutput string + + // FallbackPatterns is a list of regex patterns to match against + FallbackPatterns []*regexp.Regexp + + // SuccessMessage is the message to log in case of success + SuccessMessage string + + // ExpectedReapedJobEntries is the list of entries expected after the command output appears + ExpectedReapedJobEntries []*BackgroundJobStatusEntry + + // ShouldSkipCurrentPromptAssertion should be set to true if the prompt symbol is not expected in the command reflection + // This is usually true when a background command's output has consumed the current prompt line + ShouldSkipCurrentPromptAssertion bool +} + +func (t CommandResponseWithReapedJobsTestCase) Run(asserter *logged_shell_asserter.LoggedShellAsserter, shell *shell_executable.ShellExecutable, logger *logger.Logger) error { + allSingleLinesAssertion := []assertions.SingleLineAssertion{} + + // Add assertion for command's actual output first + allSingleLinesAssertion = append(allSingleLinesAssertion, assertions.SingleLineAssertion{ + ExpectedOutput: t.ExpectedCommandOutput, + FallbackPatterns: t.FallbackPatterns, + }) + + // Add assertion for reaped job entries now + for _, expectedOutputEntry := range t.ExpectedReapedJobEntries { + expectedOutput, regexPattern := expectedOutputEntry.ExpectedOutputAndRegex() + + allSingleLinesAssertion = append(allSingleLinesAssertion, assertions.SingleLineAssertion{ + ExpectedOutput: expectedOutput, + FallbackPatterns: []*regexp.Regexp{regexPattern}, + }) + } + + // A small delay to ensure that the grep process has exitted + time.Sleep(time.Millisecond) + + commandWithMultilineResponseTestCase := CommandWithMultilineResponseTestCase{ + Command: t.Command, + MultiLineAssertion: assertions.MultiLineAssertion{ + SingleLineAssertions: allSingleLinesAssertion, + }, + ShouldSkipCurrentPromptAssertion: t.ShouldSkipCurrentPromptAssertion, + SuccessMessage: t.SuccessMessage, + } + + if err := commandWithMultilineResponseTestCase.Run(asserter, shell, logger); err != nil { + return err + } + + return nil +} diff --git a/internal/test_cases/command_with_multiline_response_test_case.go b/internal/test_cases/command_with_multiline_response_test_case.go index bf640697..cdcc1903 100644 --- a/internal/test_cases/command_with_multiline_response_test_case.go +++ b/internal/test_cases/command_with_multiline_response_test_case.go @@ -19,8 +19,11 @@ type CommandWithMultilineResponseTestCase struct { // SuccessMessage is the message to log in case of success SuccessMessage string - // SkipAssertPrompt is a flag to indicate that the prompt should not be asserted - SkipPromptAssertion bool + // ShouldSkipCurrentPromptAssertion should be set if prompt is not expected in the command reflection + ShouldSkipCurrentPromptAssertion bool + + // ShouldSkipNextPromptAssertion is a flag to indicate that the prompt should not be asserted + ShouldSkipNextPromptAssertion bool } func (t CommandWithMultilineResponseTestCase) Run(asserter *logged_shell_asserter.LoggedShellAsserter, shell *shell_executable.ShellExecutable, logger *logger.Logger) error { @@ -28,14 +31,21 @@ func (t CommandWithMultilineResponseTestCase) Run(asserter *logged_shell_asserte return fmt.Errorf("Error sending command to shell: %v", err) } - commandReflection := fmt.Sprintf("$ %s", t.Command) + var commandReflection string + + if t.ShouldSkipCurrentPromptAssertion { + commandReflection = t.Command + } else { + commandReflection = fmt.Sprintf("$ %s", t.Command) + } + asserter.AddAssertion(assertions.SingleLineAssertion{ ExpectedOutput: commandReflection, }) asserter.AddAssertion(&t.MultiLineAssertion) - if !t.SkipPromptAssertion { + if !t.ShouldSkipNextPromptAssertion { if err := asserter.AssertWithPrompt(); err != nil { return err } diff --git a/internal/test_cases/jobs_builtin_response_test_case.go b/internal/test_cases/jobs_builtin_response_test_case.go index 3d5da607..0b37b147 100644 --- a/internal/test_cases/jobs_builtin_response_test_case.go +++ b/internal/test_cases/jobs_builtin_response_test_case.go @@ -3,7 +3,6 @@ package test_cases import ( "fmt" "regexp" - "strings" "github.com/codecrafters-io/shell-tester/internal/assertions" "github.com/codecrafters-io/shell-tester/internal/logged_shell_asserter" @@ -17,10 +16,10 @@ const ( PreviousJob ) -type JobsBuiltinOutputEntry struct { +type BackgroundJobStatusEntry struct { // The job number value in the square brackets JobNumber int - // Status: "Running", "Done", "Terminated", etc + // Status: "Running", "Done", "Terminated", "1 Exit", etc Status string // LaunchCommand: Command that was run and sent to the background without trailing & LaunchCommand string @@ -28,9 +27,46 @@ type JobsBuiltinOutputEntry struct { Marker int } +// ExpectedOutputAndRegex returns the expected output string and regex pattern for this job entry. +func (e BackgroundJobStatusEntry) ExpectedOutputAndRegex() (string, *regexp.Regexp) { + expectedJobMarkerString := convertJobMarkerToString(e.Marker) + + // This regex aims to match lines like: [1]+ Running sleep 5 & + regexString := fmt.Sprintf( + `^\[%d\]\s*%s\s+(?i)%s\s+(?-i)%s`, + e.JobNumber, + regexp.QuoteMeta(expectedJobMarkerString), + regexp.QuoteMeta(e.Status), + regexp.QuoteMeta(e.LaunchCommand), + ) + + // For 'Running' jobs, bash displays the trailing & sign + // Users shall comply with bash for consistency (Ensured this by appending this to expected output) + // But this should be optional since ZSH doesn't use this + if e.Status == "Running" { + regexString += "( &)?$" + } else { + regexString += "$" + } + + expectedOutput := fmt.Sprintf( + "[%d]%s %s %s", + e.JobNumber, expectedJobMarkerString, e.Status, e.LaunchCommand, + ) + + // For 'Running' jobs, the trailing sign is expected + if e.Status == "Running" { + expectedOutput += " &" + } + + return expectedOutput, regexp.MustCompile(regexString) +} + type JobsBuiltinResponseTestCase struct { - ExpectedOutputItems []JobsBuiltinOutputEntry - SuccessMessage string + ExpectedOutputEntries []BackgroundJobStatusEntry + // ShouldSkipCurrentPromptAssertion should be set to true if the prompt symbol is not expected in the 'jobs' command reflection + ShouldSkipCurrentPromptAssertion bool + SuccessMessage string } func (t JobsBuiltinResponseTestCase) Run(asserter *logged_shell_asserter.LoggedShellAsserter, shell *shell_executable.ShellExecutable, logger *logger.Logger) (err error) { @@ -46,53 +82,40 @@ func (t JobsBuiltinResponseTestCase) Run(asserter *logged_shell_asserter.LoggedS return fmt.Errorf("Error sending command to shell: %v", err) } - commandReflection := fmt.Sprintf("$ %s", command) + var commandReflection string + + if !t.ShouldSkipCurrentPromptAssertion { + commandReflection = fmt.Sprintf("$ %s", command) + } else { + commandReflection = command + } + asserter.AddAssertion(assertions.SingleLineAssertion{ ExpectedOutput: commandReflection, }) - // If we don't expect any items directly assert next prompt - if len(t.ExpectedOutputItems) == 0 { + // In case of no output entries, assert only the command reflection + if len(t.ExpectedOutputEntries) == 0 { return asserter.AssertWithPrompt() } - for i, outputEntry := range t.ExpectedOutputItems { - marker := convertJobMarkerToString(outputEntry.Marker) - regexString := fmt.Sprintf( - `^\[%d\]\s*%s\s+(?i)%s\s+(?-i)%s`, - outputEntry.JobNumber, - marker, - regexp.QuoteMeta(outputEntry.Status), - regexp.QuoteMeta(outputEntry.LaunchCommand), - ) - - // For 'running' jobs, bash displays the trailing & sign - // This is optional since ZSH doesn't use this - if strings.ToLower(outputEntry.Status) == "running" { - regexString += "( &)?$" - } else { - regexString += "$" - } - - regex := regexp.MustCompile(regexString) + for i, expectedOutputEntry := range t.ExpectedOutputEntries { + expectedOutput, regexPattern := expectedOutputEntry.ExpectedOutputAndRegex() asserter.AddAssertion(assertions.SingleLineAssertion{ - ExpectedOutput: fmt.Sprintf( - "[%d]%s %s %s", - outputEntry.JobNumber, marker, outputEntry.Status, outputEntry.LaunchCommand, - ), - FallbackPatterns: []*regexp.Regexp{regex}, + ExpectedOutput: expectedOutput, + FallbackPatterns: []*regexp.Regexp{regexPattern}, }) - shouldAssertWithPrompt := false + assertWithPrompt := false + var err error - if i == len(t.ExpectedOutputItems)-1 { - shouldAssertWithPrompt = true + if i == len(t.ExpectedOutputEntries)-1 { + assertWithPrompt = true } - var err error - - if shouldAssertWithPrompt { + // Assert with prompt on last entry + if assertWithPrompt { err = asserter.AssertWithPrompt() } else { err = asserter.AssertWithoutPrompt() @@ -101,6 +124,7 @@ func (t JobsBuiltinResponseTestCase) Run(asserter *logged_shell_asserter.LoggedS if err != nil { return err } + } return nil diff --git a/internal/test_helpers/course_definition.yml b/internal/test_helpers/course_definition.yml index 3c632d5d..b6fc6041 100644 --- a/internal/test_helpers/course_definition.yml +++ b/internal/test_helpers/course_definition.yml @@ -1259,6 +1259,48 @@ stages: marketing_md: |- In this stage, you'll implement launching commands in the background. + - slug: "jd6" + primary_extension_slug: "background-jobs" + name: "List a single job" + difficulty: medium + marketing_md: |- + In this stage, you'll implement the `jobs` builtin so it can list a single background job. + + - slug: "dk5" + primary_extension_slug: "background-jobs" + name: "List multiple jobs" + difficulty: medium + marketing_md: |- + In this stage, you'll extend the `jobs` builtin so it can list background jobs. + + - slug: "ma9" + primary_extension_slug: "background-jobs" + name: "Reaping one job using jobs" + difficulty: medium + marketing_md: |- + In this stage, you'll extend the `jobs` builtin to reap a single job. + + - slug: "rq2" + primary_extension_slug: "background-jobs" + name: "Reap multiple jobs using jobs" + difficulty: medium + marketing_md: |- + In this stage, you'll extend the `jobs` builtin to reap multiple jobs and update markers dynamically. + + - slug: "bv8" + primary_extension_slug: "background-jobs" + name: "Reap before the next prompt" + difficulty: medium + marketing_md: |- + In this stage, you'll add suport for reaping jobs before printing the next prompt. + + - slug: "fy4" + primary_extension_slug: "background-jobs" + name: "Job number reset" + difficulty: easy + marketing_md: |- + In this stage, you'll implement recycling job number indices. + - slug: "br6" primary_extension_slug: "pipelines" name: "Dual-command pipeline" diff --git a/internal/test_helpers/fixtures/background_jobs_job_number_not_recycled b/internal/test_helpers/fixtures/background_jobs_job_number_not_recycled new file mode 100644 index 00000000..a0825871 --- /dev/null +++ b/internal/test_helpers/fixtures/background_jobs_job_number_not_recycled @@ -0,0 +1,29 @@ +Debug = true + +[tester::#FY4] Running tests for Stage #FY4 (fy4) +[tester::#FY4] [setup] mkfifo /tmp/pear-70 +[tester::#FY4] Running ./your_program.sh +[your-program] $ grep raspberry /tmp/pear-70 & +[your-program] [1] 2556 +[tester::#FY4] ✓ Found process with PID 2556 +[tester::#FY4] ✓ Output includes job number with PID +[tester::#FY4] [setup] echo "raspberry" > "/tmp/pear-70" +[your-program] $ raspberry +[tester::#FY4] ✓ Output of 'grep raspberry /tmp/pear-70' found +[your-program] echo blueberry +[your-program] blueberry +[your-program] [1]+ Done grep raspberry /tmp/pear-70 +[tester::#FY4] ✓ Received output for echo followed by an entry for the reaped job +[your-program] $ echo mango +[your-program] mango +[tester::#FY4] ✓ Received output for echo +[your-program] $ jobs +[tester::#FY4] ✓ No jobs +[your-program] $ sleep 10 & +[your-program] [2] 2558 +[tester::#FY4] ^ Line does not match expected value. +[tester::#FY4] Expected: "[1] " +[tester::#FY4] Received: "[2] 2558" +[your-program] $  +[tester::#FY4] Assertion failed. +[tester::#FY4] Test failed diff --git a/internal/test_helpers/fixtures/background_jobs_jobs_builtin_incorrect_marker b/internal/test_helpers/fixtures/background_jobs_jobs_builtin_incorrect_marker new file mode 100644 index 00000000..a3e35a7b --- /dev/null +++ b/internal/test_helpers/fixtures/background_jobs_jobs_builtin_incorrect_marker @@ -0,0 +1,16 @@ +Debug = true + +[tester::#DK5] Running tests for Stage #DK5 (dk5) +[tester::#DK5] Running ./your_program.sh +[your-program] $ sleep 100 & +[your-program] [1] 2780 +[tester::#DK5] ✓ Found process with PID 2780 +[tester::#DK5] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]- Running sleep 100 & +[tester::#DK5] ^ Line does not match expected value. +[tester::#DK5] Expected: "[1]+ Running sleep 100 &" +[tester::#DK5] Received: "[1]- Running sleep 100 &" +[your-program] $  +[tester::#DK5] Assertion failed. +[tester::#DK5] Test failed diff --git a/internal/test_helpers/fixtures/background_jobs_jobs_builtin_incorrect_output_format b/internal/test_helpers/fixtures/background_jobs_jobs_builtin_incorrect_output_format new file mode 100644 index 00000000..6e26caa3 --- /dev/null +++ b/internal/test_helpers/fixtures/background_jobs_jobs_builtin_incorrect_output_format @@ -0,0 +1,16 @@ +Debug = true + +[tester::#DK5] Running tests for Stage #DK5 (dk5) +[tester::#DK5] Running ./your_program.sh +[your-program] $ sleep 100 & +[your-program] [1] 2969 +[tester::#DK5] ✓ Found process with PID 2969 +[tester::#DK5] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]+Running sleep 100 & +[tester::#DK5] ^ Line does not match expected value. +[tester::#DK5] Expected: "[1]+ Running sleep 100 &" +[tester::#DK5] Received: "[1]+Running sleep 100 &" +[your-program] $  +[tester::#DK5] Assertion failed. +[tester::#DK5] Test failed diff --git a/internal/test_helpers/fixtures/background_jobs_jobs_builtin_not_reaped b/internal/test_helpers/fixtures/background_jobs_jobs_builtin_not_reaped new file mode 100644 index 00000000..50d78a1d --- /dev/null +++ b/internal/test_helpers/fixtures/background_jobs_jobs_builtin_not_reaped @@ -0,0 +1,31 @@ +Debug = true + +[tester::#RQ2] Running tests for Stage #RQ2 (rq2) +[tester::#RQ2] [setup] mkfifo /tmp/apple-80 +[tester::#RQ2] [setup] mkfifo /tmp/blueberry-54 +[tester::#RQ2] Running ./your_program.sh +[your-program] $ sleep 500 & +[your-program] [1] 2784 +[tester::#RQ2] ✓ Found process with PID 2784 +[tester::#RQ2] ✓ Output includes job number with PID +[your-program] $ grep apple /tmp/apple-80 & +[your-program] [2] 2786 +[tester::#RQ2] ✓ Found process with PID 2786 +[tester::#RQ2] ✓ Output includes job number with PID +[your-program] $ grep apple /tmp/blueberry-54 & +[your-program] [3] 2788 +[tester::#RQ2] ✓ Found process with PID 2788 +[tester::#RQ2] ✓ Output includes job number with PID +[tester::#RQ2] [setup] echo "apple" > "/tmp/apple-80" +[your-program] $ apple +[tester::#RQ2] ✓ Output of 'grep apple /tmp/apple-80' found +[your-program] jobs +[your-program] [1] Running sleep 500 & +[your-program] [2]- Running grep apple /tmp/apple-80 & +[tester::#RQ2] ^ Line does not match expected value. +[tester::#RQ2] Expected: "[2]- Done grep apple /tmp/apple-80" +[tester::#RQ2] Received: "[2]- Running grep apple /tmp/apple-80 &" +[your-program] [3]+ Running grep apple /tmp/blueberry-54 & +[your-program] $  +[tester::#RQ2] Assertion failed. +[tester::#RQ2] Test failed diff --git a/internal/test_helpers/fixtures/bash/background_jobs/pass b/internal/test_helpers/fixtures/bash/background_jobs/pass index dc47781e..4e9f59f3 100644 --- a/internal/test_helpers/fixtures/bash/background_jobs/pass +++ b/internal/test_helpers/fixtures/bash/background_jobs/pass @@ -4,7 +4,7 @@ Debug = true [tester::#AF3] Running ./your_shell.sh [your-program] $ type jobs [your-program] jobs is a shell builtin -[tester::#AF3] Expected type for 'jobs' found +[tester::#AF3] ✓ Received expected response [your-program] $ jobs [tester::#AF3] ✓ Received empty response [your-program] $  @@ -13,8 +13,188 @@ Debug = true [tester::#AT7] Running tests for Stage #AT7 (at7) [tester::#AT7] Running ./your_shell.sh [your-program] $ sleep 100 & -[your-program] [1] 2771 -[tester::#AT7] ✓ Found process with PID 2771 +[your-program] [1] 2287 +[tester::#AT7] ✓ Found process with PID 2287 [tester::#AT7] ✓ Received next prompt [your-program] $  [tester::#AT7] Test passed. + +[tester::#JD6] Running tests for Stage #JD6 (jd6) +[tester::#JD6] Running ./your_shell.sh +[your-program] $ sleep 100 & +[your-program] [1] 2290 +[tester::#JD6] ✓ Found process with PID 2290 +[tester::#JD6] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]+ Running sleep 100 & +[tester::#JD6] ✓ 1 entry matches the running job +[your-program] $  +[tester::#JD6] Test passed. + +[tester::#DK5] Running tests for Stage #DK5 (dk5) +[tester::#DK5] Running ./your_shell.sh +[your-program] $ sleep 100 & +[your-program] [1] 2294 +[tester::#DK5] ✓ Found process with PID 2294 +[tester::#DK5] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]+ Running sleep 100 & +[tester::#DK5] ✓ 1 entry match the running job +[your-program] $ sleep 200 & +[your-program] [2] 2297 +[tester::#DK5] ✓ Found process with PID 2297 +[tester::#DK5] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]- Running sleep 100 & +[your-program] [2]+ Running sleep 200 & +[tester::#DK5] ✓ 2 entries match the running jobs +[your-program] $ sleep 300 & +[your-program] [3] 2299 +[tester::#DK5] ✓ Found process with PID 2299 +[tester::#DK5] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1] Running sleep 100 & +[your-program] [2]- Running sleep 200 & +[your-program] [3]+ Running sleep 300 & +[tester::#DK5] ✓ 3 entries match the running jobs +[your-program] $  +[tester::#DK5] Test passed. + +[tester::#MA9] Running tests for Stage #MA9 (ma9) +[tester::#MA9] Running ./your_shell.sh +[tester::#MA9] [setup] mkfifo /tmp/pear-70 +[your-program] $ grep raspberry /tmp/pear-70 & +[your-program] [1] 2302 +[tester::#MA9] ✓ Found process with PID 2302 +[tester::#MA9] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]+ Running grep raspberry /tmp/pear-70 & +[tester::#MA9] ✓ 1 entry matches the running job +[tester::#MA9] [setup] echo "raspberry" > "/tmp/pear-70" +[your-program] $ raspberry +[tester::#MA9] ✓ Output of background command 'grep' found +[your-program] jobs +[your-program] [1]+ Done grep raspberry /tmp/pear-70 +[tester::#MA9] ✓ 1 entry matches the finished job +[your-program] $  +[tester::#MA9] Test passed. + +[tester::#RQ2] Running tests for Stage #RQ2 (rq2) +[tester::#RQ2] [setup] mkfifo /tmp/blueberry-26 +[tester::#RQ2] [setup] mkfifo /tmp/mango-16 +[tester::#RQ2] Running ./your_shell.sh +[your-program] $ sleep 500 & +[your-program] [1] 2306 +[tester::#RQ2] ✓ Found process with PID 2306 +[tester::#RQ2] ✓ Output includes job number with PID +[your-program] $ grep mango /tmp/blueberry-26 & +[your-program] [2] 2308 +[tester::#RQ2] ✓ Found process with PID 2308 +[tester::#RQ2] ✓ Output includes job number with PID +[your-program] $ grep grape /tmp/mango-16 & +[your-program] [3] 2310 +[tester::#RQ2] ✓ Found process with PID 2310 +[tester::#RQ2] ✓ Output includes job number with PID +[tester::#RQ2] [setup] echo "mango" > "/tmp/blueberry-26" +[your-program] $ mango +[tester::#RQ2] ✓ Output of 'grep mango /tmp/blueberry-26' found +[your-program] jobs +[your-program] [1] Running sleep 500 & +[your-program] [2]- Done grep mango /tmp/blueberry-26 +[your-program] [3]+ Running grep grape /tmp/mango-16 & +[tester::#RQ2] ✓ Received 3 entries in the output +[tester::#RQ2] [setup] echo "grape" > "/tmp/mango-16" +[your-program] $ grape +[tester::#RQ2] ✓ Output of 'grep grape /tmp/mango-16' found +[your-program] jobs +[your-program] [1]- Running sleep 500 & +[your-program] [3]+ Done grep grape /tmp/mango-16 +[tester::#RQ2] ✓ Received 2 entries in the output +[your-program] $ jobs +[your-program] [1]+ Running sleep 500 & +[tester::#RQ2] ✓ 1 entry matches the running job +[your-program] $  +[tester::#RQ2] Test passed. + +[tester::#BV8] Running tests for Stage #BV8 (bv8) +[tester::#BV8] [setup] mkfifo /tmp/banana-47 +[tester::#BV8] Running ./your_shell.sh +[your-program] $ sleep 500 & +[your-program] [1] 2315 +[tester::#BV8] ✓ Found process with PID 2315 +[tester::#BV8] ✓ Output includes job number with PID +[your-program] $ grep apple /tmp/banana-47 & +[your-program] [2] 2317 +[tester::#BV8] ✓ Found process with PID 2317 +[tester::#BV8] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]- Running sleep 500 & +[your-program] [2]+ Running grep apple /tmp/banana-47 & +[tester::#BV8] ✓ Found 2 entries for the running jobs +[tester::#BV8] [setup] echo "apple" > "/tmp/banana-47" +[your-program] $ apple +[tester::#BV8] ✓ Output of 'grep apple /tmp/banana-47' found +[your-program] echo pear +[your-program] pear +[your-program] [2]+ Done grep apple /tmp/banana-47 +[tester::#BV8] ✓ Found command output followed by an entry for the reaped job +[your-program] $ jobs +[your-program] [1]+ Running sleep 500 & +[tester::#BV8] ✓ 1 entry matches the running job +[your-program] $  +[tester::#BV8] Test passed. + +[tester::#FY4] Running tests for Stage #FY4 (fy4) +[tester::#FY4] [setup] mkfifo /tmp/banana-39 +[tester::#FY4] Running ./your_shell.sh +[your-program] $ grep raspberry /tmp/banana-39 & +[your-program] [1] 2321 +[tester::#FY4] ✓ Found process with PID 2321 +[tester::#FY4] ✓ Output includes job number with PID +[tester::#FY4] [setup] echo "raspberry" > "/tmp/banana-39" +[your-program] $ raspberry +[tester::#FY4] ✓ Output of 'grep raspberry /tmp/banana-39' found +[your-program] echo banana +[your-program] banana +[your-program] [1]+ Done grep raspberry /tmp/banana-39 +[tester::#FY4] ✓ Received output for echo followed by an entry for the reaped job +[your-program] $ echo apple +[your-program] apple +[tester::#FY4] ✓ Received output for echo +[your-program] $ jobs +[tester::#FY4] ✓ No jobs +[your-program] $ sleep 10 & +[your-program] [1] 2326 +[tester::#FY4] ✓ Found process with PID 2326 +[tester::#FY4] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]+ Running sleep 10 & +[tester::#FY4] ✓ 1 entry matches the running job +[tester::#FY4] Tearing down shell +[tester::#FY4] [setup] mkfifo /tmp/strawberry-88 +[tester::#FY4] Running ./your_shell.sh +[your-program] $ sleep 100 & +[your-program] [1] 2330 +[tester::#FY4] ✓ Found process with PID 2330 +[tester::#FY4] ✓ Output includes job number with PID +[your-program] $ grep pineapple /tmp/strawberry-88 & +[your-program] [2] 2332 +[tester::#FY4] ✓ Found process with PID 2332 +[tester::#FY4] ✓ Output includes job number with PID +[tester::#FY4] [setup] echo "pineapple" > "/tmp/strawberry-88" +[your-program] $ pineapple +[tester::#FY4] ✓ Output of 'grep pineapple /tmp/strawberry-88' found +[your-program] echo apple +[your-program] apple +[your-program] [2]+ Done grep pineapple /tmp/strawberry-88 +[tester::#FY4] ✓ Received output for echo followed by an entry for the reaped job +[your-program] $ sleep 50 & +[your-program] [2] 2334 +[tester::#FY4] ✓ Found process with PID 2334 +[tester::#FY4] ✓ Output includes job number with PID +[your-program] $ jobs +[your-program] [1]- Running sleep 100 & +[your-program] [2]+ Running sleep 50 & +[tester::#FY4] ✓ 2 entries match the running jobs +[your-program] $  +[tester::#FY4] Test passed. diff --git a/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/codecrafters.yml b/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/codecrafters.yml new file mode 100644 index 00000000..a3e7b727 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/codecrafters.yml @@ -0,0 +1 @@ +debug: true \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/main.py b/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/main.py new file mode 100644 index 00000000..c1df8a35 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/main.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Minimal shell that passes base stages oo8--ip1: prompt, invalid command, REPL, exit, echo, type, run.""" + +import cmd +import os +import sys +import subprocess +import shlex + +PROMPT = "$ " +BUILTINS = {"echo", "exit", "type", "jobs"} + +# List of background jobs: {"job_id": int, "cmd_line": str, "process": Popen} +BACKGROUND_JOBS: list[dict] = [] +# Next job number to assign; never recycled after reaping (intentional error scenario). +NEXT_JOB_ID: int = 1 + + +def reap_finished_jobs() -> None: + """Remove any background job whose process has exited.""" + global BACKGROUND_JOBS + BACKGROUND_JOBS = [j for j in BACKGROUND_JOBS if j["process"].poll() is None] + +def notify_and_reap_jobs() -> None: + """Print finished jobs, then remove them.""" + global BACKGROUND_JOBS + alive = [] + for job in BACKGROUND_JOBS: + proc = job["process"] + if proc.poll() is not None: + jid = job["job_id"] + cmd_line = job["cmd_line"] + # For Done, don't print trailing & + cmd_line = cmd_line.removesuffix(" &") + # Since this is only used for fy4; hardcoding this for now + print(f"[{jid}]+ Done {cmd_line}") + else: + alive.append(job) + BACKGROUND_JOBS = alive + +def find_in_path(name: str) -> str | None: + """First executable in PATH with this name (skip non-executable).""" + path = os.environ.get("PATH", "") + for part in path.split(os.pathsep): + if not part: + continue + full = os.path.join(part, name) + if os.path.isfile(full) and os.access(full, os.X_OK): + return full + return None + + +def run_builtin(args: list[str], last_exit_code: int) -> tuple[str | None, int | None]: + """Run builtin. Returns (output_line_or_None, exit_code_or_None). None means continue (no exit).""" + if not args: + return None, None + cmd = args[0].lower() + if cmd == "exit": + code = int(args[1]) if len(args) > 1 else last_exit_code + if code < 0: + code = 0 + return None, code + if cmd == "echo": + out = " ".join(args[1:]) if len(args) > 1 else "" + return out, None + if cmd == "type": + if len(args) < 2: + return None, None + name = args[1] + if name in BUILTINS: + return f"{name} is a shell builtin", None + path = find_in_path(name) + if path: + return f"{name} is {path}", None + return f"{name}: not found", None + if cmd == "jobs": + reap_finished_jobs() + lines = [] + for i, job in enumerate(BACKGROUND_JOBS): + jid = job["job_id"] + cmd_line = job["cmd_line"] + # + for current (last), - for previous (second-last), space otherwise + n = len(BACKGROUND_JOBS) + if i == n - 1: + marker = "+" + elif n >= 2 and i == n - 2: + marker = "-" + else: + marker = " " + line = f"[{jid}]{marker} Running {cmd_line}" + lines.append(line) + return "\n".join(lines) if lines else None, None + return None, None + + +def run_external(args: list[str], background: bool = False) -> tuple[int | None, subprocess.Popen | None]: + """Run external command. Returns (exit_code, None) or (None, proc) if background.""" + name = args[0] + path = find_in_path(name) + if path is None: + print(f"{name}: command not found", file=sys.stderr) + return (127, None) + try: + if background: + proc = subprocess.Popen( + [path] + args[1:], + env=os.environ, + ) + return (None, proc) + proc = subprocess.run( + [path] + args[1:], + env=os.environ, + capture_output=False, + timeout=30, + ) + return (proc.returncode, None) + except (OSError, subprocess.TimeoutExpired): + return (127, None) + + +def main() -> None: + global NEXT_JOB_ID + last_exit_code = 0 + while True: + sys.stdout.write(PROMPT) + sys.stdout.flush() + try: + line = sys.stdin.readline() + except (EOFError, KeyboardInterrupt): + break + if not line: + break + line = line.rstrip("\n\r") + background = line.rstrip().endswith("&") + if background: + line = line.rstrip()[:-1].rstrip() + parts = shlex.split(line) + if not parts: + continue + cmd = parts[0] + args = parts[1:] + + if cmd in BUILTINS: + out, exit_code = run_builtin([cmd] + args, last_exit_code) + if exit_code is not None: + sys.exit(exit_code) + if out is not None: + print(out) + sys.stdout.flush() + else: + exit_code, bg_proc = run_external([cmd] + args, background=background) + if exit_code is not None: + last_exit_code = exit_code + else: + job_id = NEXT_JOB_ID + NEXT_JOB_ID += 1 + cmd_line = " ".join([cmd] + args) + " &" + BACKGROUND_JOBS.append({"job_id": job_id, "cmd_line": cmd_line, "process": bg_proc}) + print(f"[{job_id}] {bg_proc.pid}") + sys.stdout.flush() + notify_and_reap_jobs() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/your_program.sh b/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/your_program.sh new file mode 100755 index 00000000..f667ad73 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_job_number_not_recycled/your_program.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# This buggy implementation of shell will never recycle job numbers and keep on increasing +exec python3 $(dirname "$0")/main.py "$@" \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/codecrafters.yml b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/codecrafters.yml new file mode 100644 index 00000000..a3e7b727 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/codecrafters.yml @@ -0,0 +1 @@ +debug: true \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/main.py b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/main.py new file mode 100644 index 00000000..0c080a67 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/main.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Minimal shell that passes base stages oo8--ip1: prompt, invalid command, REPL, exit, echo, type, run.""" + +import os +import sys +import subprocess +import shlex + +PROMPT = "$ " +BUILTINS = {"echo", "exit", "type", "jobs"} + +# List of background jobs: {"job_id": int, "cmd_line": str, "process": Popen} +BACKGROUND_JOBS: list[dict] = [] + + +def find_in_path(name: str) -> str | None: + """First executable in PATH with this name (skip non-executable).""" + path = os.environ.get("PATH", "") + for part in path.split(os.pathsep): + if not part: + continue + full = os.path.join(part, name) + if os.path.isfile(full) and os.access(full, os.X_OK): + return full + return None + + +def run_builtin(args: list[str], last_exit_code: int) -> tuple[str | None, int | None]: + """Run builtin. Returns (output_line_or_None, exit_code_or_None). None means continue (no exit).""" + if not args: + return None, None + cmd = args[0].lower() + if cmd == "exit": + code = int(args[1]) if len(args) > 1 else last_exit_code + if code < 0: + code = 0 + return None, code + if cmd == "echo": + out = " ".join(args[1:]) if len(args) > 1 else "" + return out, None + if cmd == "type": + if len(args) < 2: + return None, None + name = args[1] + if name in BUILTINS: + return f"{name} is a shell builtin", None + path = find_in_path(name) + if path: + return f"{name} is {path}", None + return f"{name}: not found", None + if cmd == "jobs": + lines = [] + for i, job in enumerate(BACKGROUND_JOBS): + jid = job["job_id"] + cmd_line = job["cmd_line"] + # + for current (last), - for previous (second-last), space otherwise + n = len(BACKGROUND_JOBS) + if i == n - 1: + marker = "-" + elif n >= 2 and i == n - 2: + marker = "+" + else: + marker = " " + line = f"[{jid}]{marker} Running {cmd_line}" + lines.append(line) + return "\n".join(lines) if lines else "", None + return None, None + + +def run_external(args: list[str], background: bool = False) -> tuple[int | None, subprocess.Popen | None]: + """Run external command. Returns (exit_code, None) or (None, proc) if background.""" + name = args[0] + path = find_in_path(name) + if path is None: + print(f"{name}: command not found", file=sys.stderr) + return (127, None) + try: + if background: + proc = subprocess.Popen( + [path] + args[1:], + env=os.environ, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return (None, proc) + proc = subprocess.run( + [path] + args[1:], + env=os.environ, + capture_output=False, + timeout=30, + ) + return (proc.returncode, None) + except (OSError, subprocess.TimeoutExpired): + return (127, None) + + +def main() -> None: + last_exit_code = 0 + while True: + sys.stdout.write(PROMPT) + sys.stdout.flush() + try: + line = sys.stdin.readline() + except (EOFError, KeyboardInterrupt): + break + if not line: + break + line = line.rstrip("\n\r") + background = line.rstrip().endswith("&") + if background: + line = line.rstrip()[:-1].rstrip() + parts = shlex.split(line) + if not parts: + continue + cmd = parts[0] + args = parts[1:] + + if cmd in BUILTINS: + out, exit_code = run_builtin([cmd] + args, last_exit_code) + if exit_code is not None: + sys.exit(exit_code) + if out is not None: + print(out) + sys.stdout.flush() + else: + exit_code, bg_proc = run_external([cmd] + args, background=background) + if exit_code is not None: + last_exit_code = exit_code + else: + job_id = len(BACKGROUND_JOBS) + 1 + cmd_line = " ".join([cmd] + args) + " &" + BACKGROUND_JOBS.append({"job_id": job_id, "cmd_line": cmd_line, "process": bg_proc}) + print(f"[{job_id}] {bg_proc.pid}") + sys.stdout.flush() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/your_program.sh b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/your_program.sh new file mode 100755 index 00000000..e5a72dfc --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_marker/your_program.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# This buggy implementation of shell will always print incorrect output of the jobs builtin +exec python3 $(dirname "$0")/main.py "$@" \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/codecrafters.yml b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/codecrafters.yml new file mode 100644 index 00000000..a3e7b727 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/codecrafters.yml @@ -0,0 +1 @@ +debug: true \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/main.py b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/main.py new file mode 100644 index 00000000..d7d27446 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/main.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Minimal shell that passes base stages oo8--ip1: prompt, invalid command, REPL, exit, echo, type, run.""" + +import os +import sys +import subprocess +import shlex + +PROMPT = "$ " +BUILTINS = {"echo", "exit", "type", "jobs"} + +# List of background jobs: {"job_id": int, "cmd_line": str, "process": Popen} +BACKGROUND_JOBS: list[dict] = [] + +def find_in_path(name: str) -> str | None: + """First executable in PATH with this name (skip non-executable).""" + path = os.environ.get("PATH", "") + for part in path.split(os.pathsep): + if not part: + continue + full = os.path.join(part, name) + if os.path.isfile(full) and os.access(full, os.X_OK): + return full + return None + + +def run_builtin(args: list[str], last_exit_code: int) -> tuple[str | None, int | None]: + """Run builtin. Returns (output_line_or_None, exit_code_or_None). None means continue (no exit).""" + if not args: + return None, None + cmd = args[0].lower() + if cmd == "exit": + code = int(args[1]) if len(args) > 1 else last_exit_code + if code < 0: + code = 0 + return None, code + if cmd == "echo": + out = " ".join(args[1:]) if len(args) > 1 else "" + return out, None + if cmd == "type": + if len(args) < 2: + return None, None + name = args[1] + if name in BUILTINS: + return f"{name} is a shell builtin", None + path = find_in_path(name) + if path: + return f"{name} is {path}", None + return f"{name}: not found", None + if cmd == "jobs": + lines = [] + for i, job in enumerate(BACKGROUND_JOBS): + jid = job["job_id"] + cmd_line = job["cmd_line"] + # + for current (last), - for previous (second-last), space otherwise + n = len(BACKGROUND_JOBS) + if i == n - 1: + marker = "+" + elif n >= 2 and i == n - 2: + marker = "-" + else: + marker = " " + line = f"[{jid}]{marker}Running {cmd_line}" + lines.append(line) + return "\n".join(lines) if lines else "", None + return None, None + + +def run_external(args: list[str], background: bool = False) -> tuple[int | None, subprocess.Popen | None]: + """Run external command. Returns (exit_code, None) or (None, proc) if background.""" + name = args[0] + path = find_in_path(name) + if path is None: + print(f"{name}: command not found", file=sys.stderr) + return (127, None) + try: + if background: + proc = subprocess.Popen( + [path] + args[1:], + env=os.environ, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return (None, proc) + proc = subprocess.run( + [path] + args[1:], + env=os.environ, + capture_output=False, + timeout=30, + ) + return (proc.returncode, None) + except (OSError, subprocess.TimeoutExpired): + return (127, None) + + +def main() -> None: + last_exit_code = 0 + while True: + sys.stdout.write(PROMPT) + sys.stdout.flush() + try: + line = sys.stdin.readline() + except (EOFError, KeyboardInterrupt): + break + if not line: + break + line = line.rstrip("\n\r") + background = line.rstrip().endswith("&") + if background: + line = line.rstrip()[:-1].rstrip() + parts = shlex.split(line) + if not parts: + continue + cmd = parts[0] + args = parts[1:] + + if cmd in BUILTINS: + out, exit_code = run_builtin([cmd] + args, last_exit_code) + if exit_code is not None: + sys.exit(exit_code) + if out is not None: + print(out) + sys.stdout.flush() + else: + exit_code, bg_proc = run_external([cmd] + args, background=background) + if exit_code is not None: + last_exit_code = exit_code + else: + job_id = len(BACKGROUND_JOBS) + 1 + cmd_line = " ".join([cmd] + args) + " &" + BACKGROUND_JOBS.append({"job_id": job_id, "cmd_line": cmd_line, "process": bg_proc}) + print(f"[{job_id}] {bg_proc.pid}") + sys.stdout.flush() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/your_program.sh b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/your_program.sh new file mode 100755 index 00000000..d52a3bdb --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_incorrect_output_format/your_program.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# This buggy implementation of shell will always print the output of jobs builtin in a wrong format +exec python3 $(dirname "$0")/main.py "$@" \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/codecrafters.yml b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/codecrafters.yml new file mode 100644 index 00000000..a3e7b727 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/codecrafters.yml @@ -0,0 +1 @@ +debug: true \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/main.py b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/main.py new file mode 100644 index 00000000..9876cfa8 --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/main.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Minimal shell that passes base stages oo8--ip1: prompt, invalid command, REPL, exit, echo, type, run.""" + +import os +import sys +import subprocess +import shlex + +PROMPT = "$ " +BUILTINS = {"echo", "exit", "type", "jobs"} + +# List of background jobs: {"job_id": int, "cmd_line": str, "process": Popen} +BACKGROUND_JOBS: list[dict] = [] + + +def find_in_path(name: str) -> str | None: + """First executable in PATH with this name (skip non-executable).""" + path = os.environ.get("PATH", "") + for part in path.split(os.pathsep): + if not part: + continue + full = os.path.join(part, name) + if os.path.isfile(full) and os.access(full, os.X_OK): + return full + return None + + +def run_builtin(args: list[str], last_exit_code: int) -> tuple[str | None, int | None]: + """Run builtin. Returns (output_line_or_None, exit_code_or_None). None means continue (no exit).""" + if not args: + return None, None + cmd = args[0].lower() + if cmd == "exit": + code = int(args[1]) if len(args) > 1 else last_exit_code + if code < 0: + code = 0 + return None, code + if cmd == "echo": + out = " ".join(args[1:]) if len(args) > 1 else "" + return out, None + if cmd == "type": + if len(args) < 2: + return None, None + name = args[1] + if name in BUILTINS: + return f"{name} is a shell builtin", None + path = find_in_path(name) + if path: + return f"{name} is {path}", None + return f"{name}: not found", None + if cmd == "jobs": + lines = [] + for i, job in enumerate(BACKGROUND_JOBS): + jid = job["job_id"] + cmd_line = job["cmd_line"] + # + for current (last), - for previous (second-last), space otherwise + n = len(BACKGROUND_JOBS) + if i == n - 1: + marker = "+" + elif n >= 2 and i == n - 2: + marker = "-" + else: + marker = " " + line = f"[{jid}]{marker} Running {cmd_line}" + lines.append(line) + return "\n".join(lines) if lines else "", None + return None, None + + +def run_external(args: list[str], background: bool = False) -> tuple[int | None, subprocess.Popen | None]: + """Run external command. Returns (exit_code, None) or (None, proc) if background.""" + name = args[0] + path = find_in_path(name) + if path is None: + print(f"{name}: command not found", file=sys.stderr) + return (127, None) + try: + if background: + proc = subprocess.Popen( + [path] + args[1:], + env=os.environ, + ) + return (None, proc) + proc = subprocess.run( + [path] + args[1:], + env=os.environ, + capture_output=False, + timeout=30, + ) + return (proc.returncode, None) + except (OSError, subprocess.TimeoutExpired): + return (127, None) + + +def main() -> None: + last_exit_code = 0 + while True: + sys.stdout.write(PROMPT) + sys.stdout.flush() + try: + line = sys.stdin.readline() + except (EOFError, KeyboardInterrupt): + break + if not line: + break + line = line.rstrip("\n\r") + background = line.rstrip().endswith("&") + if background: + line = line.rstrip()[:-1].rstrip() + parts = shlex.split(line) + if not parts: + continue + cmd = parts[0] + args = parts[1:] + + if cmd in BUILTINS: + out, exit_code = run_builtin([cmd] + args, last_exit_code) + if exit_code is not None: + sys.exit(exit_code) + if out is not None: + print(out) + sys.stdout.flush() + else: + exit_code, bg_proc = run_external([cmd] + args, background=background) + if exit_code is not None: + last_exit_code = exit_code + else: + job_id = len(BACKGROUND_JOBS) + 1 + cmd_line = " ".join([cmd] + args) + " &" + BACKGROUND_JOBS.append({"job_id": job_id, "cmd_line": cmd_line, "process": bg_proc}) + print(f"[{job_id}] {bg_proc.pid}") + sys.stdout.flush() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/your_program.sh b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/your_program.sh new file mode 100755 index 00000000..ac0b626a --- /dev/null +++ b/internal/test_helpers/scenarios/background_jobs_jobs_builtin_not_reaped/your_program.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# This buggy implementation of shell will never reap the background jobs +exec python3 $(dirname "$0")/main.py "$@" \ No newline at end of file diff --git a/internal/tester_definition.go b/internal/tester_definition.go index 8cfc5a93..ba73c7f4 100644 --- a/internal/tester_definition.go +++ b/internal/tester_definition.go @@ -202,6 +202,36 @@ var testerDefinition = tester_definition.TesterDefinition{ TestFunc: testBG2, Timeout: 15 * time.Second, }, + { + Slug: "jd6", + TestFunc: testBG3, + Timeout: 15 * time.Second, + }, + { + Slug: "dk5", + TestFunc: testBG4, + Timeout: 15 * time.Second, + }, + { + Slug: "ma9", + TestFunc: testBG5, + Timeout: 15 * time.Second, + }, + { + Slug: "rq2", + TestFunc: testBG6, + Timeout: 15 * time.Second, + }, + { + Slug: "bv8", + TestFunc: testBG7, + Timeout: 15 * time.Second, + }, + { + Slug: "fy4", + TestFunc: testBG8, + Timeout: 15 * time.Second, + }, // Pipelines { Slug: "br6", diff --git a/internal/utils_fs.go b/internal/utils_fs.go index f2668976..c3fec18a 100644 --- a/internal/utils_fs.go +++ b/internal/utils_fs.go @@ -6,6 +6,7 @@ import ( "path" "path/filepath" "strings" + "syscall" "github.com/codecrafters-io/tester-utils/logger" "github.com/codecrafters-io/tester-utils/random" @@ -159,6 +160,36 @@ func WriteFileWithTeardown(stageHarness *test_case_harness.TestCaseHarness, file return nil } +func CreateRandomFIFOWithTeardown(stageHarness *test_case_harness.TestCaseHarness, fifoPath string, permissions os.FileMode) error { + // Remove if exists first (ignore error) + _ = os.Remove(fifoPath) + + if err := syscall.Mkfifo(fifoPath, uint32(permissions)); err != nil { + return fmt.Errorf("Failed to create named pipe at %s: %w", fifoPath, err) + } + + stageHarness.Logger.WithAdditionalSecondaryPrefix("setup", func() { + stageHarness.Logger.Infof("mkfifo %s", fifoPath) + }) + + stageHarness.RegisterTeardownFunc(func() { + _ = os.Remove(fifoPath) + }) + + return nil +} + +// WriteToFile writes the provided contents to the file in the path filePath. +func WriteToFile(stageHarness *test_case_harness.TestCaseHarness, filePath string, contents string) error { + if err := os.WriteFile(filePath, []byte(contents), 0); err != nil { + return err + } + stageHarness.Logger.WithAdditionalSecondaryPrefix("setup", func() { + stageHarness.Logger.Infof("echo %q > %q", contents, filePath) + }) + return nil +} + // MustLogWorkingDirTree logs the contents of a directory as a tree (e.g. "- foo" then " - bar" for nested entries). func MustLogWorkingDirTree(logger *logger.Logger, directoryPath string) { logger.PushSecondaryPrefix("working_dir")