Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9516b3e
Add stage 3 and 4 for background launch
UdeshyaDhungana Feb 24, 2026
6a7bae5
Add standard column size to bash launcher
UdeshyaDhungana Feb 24, 2026
dd0c5ae
Update CI: GitHub test now uses docker image instead of the host system
UdeshyaDhungana Feb 24, 2026
9a57b1f
Add comment explaining test workflow
UdeshyaDhungana Feb 24, 2026
83fdd6a
Merge branch 'bg-jobs' into bg-jobs-2
UdeshyaDhungana Feb 24, 2026
d87fb88
Update log in stage bg4 to match number of output entries of jobs bui…
UdeshyaDhungana Feb 24, 2026
88d8591
Merge branch 'bg-jobs' into bg-jobs-2
UdeshyaDhungana Feb 25, 2026
26931dd
Merge branch 'bg-jobs' into bg-jobs-2
UdeshyaDhungana Feb 25, 2026
527e659
Add scenario: jobs builtin incorrect marker
UdeshyaDhungana Feb 25, 2026
befb7ea
Merge branch 'bg-jobs' into bg-jobs-2
UdeshyaDhungana Feb 25, 2026
b4990fa
Refactor jobs builtin response test case
UdeshyaDhungana Feb 25, 2026
fad4be6
Merge branch 'bg-jobs' into bg-jobs-2
UdeshyaDhungana Feb 25, 2026
075b012
Merge branch 'bg-jobs' into bg-jobs-2
UdeshyaDhungana Feb 25, 2026
9ce8f96
Improve JobsBuiltinResponseTestCase with better error logs
UdeshyaDhungana Feb 25, 2026
0775229
Improve error message in case of regex mismatch with hyperlink
UdeshyaDhungana Feb 25, 2026
126048c
Record fixtures
UdeshyaDhungana Feb 25, 2026
60568df
Correct path in test.yml
UdeshyaDhungana Feb 25, 2026
ae4f6b6
Update regex to support two-word status
UdeshyaDhungana Feb 25, 2026
c2f129e
Record fixtures
UdeshyaDhungana Feb 25, 2026
54215f7
Make trailing ? in jobs output regex optional
UdeshyaDhungana Feb 25, 2026
00efa63
Make launchCommandStr in regex non greedy and record fixtures
UdeshyaDhungana Feb 25, 2026
1973026
Merge bg-jobs to this branch
UdeshyaDhungana Feb 26, 2026
a01ec1a
Update JobsBuiltinTestCase to handle error messags well
UdeshyaDhungana Feb 26, 2026
261d3f7
Rename variables in JobsBuiltinResponseTestCase
UdeshyaDhungana Feb 26, 2026
df913a8
Assert next prompt in case of no output in JobsBuiltinResponseTestCase
UdeshyaDhungana Feb 26, 2026
761986e
Improve success message for stage 3 and 4
UdeshyaDhungana Feb 26, 2026
cd57560
Update success log in bg4 stage
UdeshyaDhungana Feb 26, 2026
03cbfda
Update success logs according to PR suggestions
UdeshyaDhungana Feb 27, 2026
5d89b5b
Record fixtures
UdeshyaDhungana Feb 27, 2026
21336ff
Fix grammar mistake in success log in bg4
UdeshyaDhungana Feb 27, 2026
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
17 changes: 5 additions & 12 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ 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"} \
]
endef

Expand Down
10 changes: 4 additions & 6 deletions internal/stage_bg1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.JobsBuiltinOutputEntry{},
}

if err := jobsTestCase.Run(asserter, shell, logger); err != nil {
Expand Down
47 changes: 47 additions & 0 deletions internal/stage_bg3.go
Original file line number Diff line number Diff line change
@@ -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.JobsBuiltinOutputEntry{{
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)
}
91 changes: 91 additions & 0 deletions internal/stage_bg4.go
Original file line number Diff line number Diff line change
@@ -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.JobsBuiltinOutputEntry, 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.JobsBuiltinOutputEntry{
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
}
16 changes: 15 additions & 1 deletion internal/stages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
CodePath: "./test_helpers/bash",
ExpectedExitCode: 0,
StdoutFixturePath: "./test_helpers/fixtures/bash/background_jobs/pass",
Expand All @@ -118,6 +118,20 @@ 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,
},
"pipelines_pass_bash": {
StageSlugs: []string{"br6", "ny9", "xk3"},
CodePath: "./test_helpers/bash",
Expand Down
62 changes: 35 additions & 27 deletions internal/test_cases/jobs_builtin_response_test_case.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -20,7 +19,7 @@ const (
type JobsBuiltinOutputEntry 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
Expand All @@ -29,8 +28,8 @@ type JobsBuiltinOutputEntry struct {
}

type JobsBuiltinResponseTestCase struct {
ExpectedOutputItems []JobsBuiltinOutputEntry
SuccessMessage string
ExpectedOutputEntries []JobsBuiltinOutputEntry
SuccessMessage string
}

func (t JobsBuiltinResponseTestCase) Run(asserter *logged_shell_asserter.LoggedShellAsserter, shell *shell_executable.ShellExecutable, logger *logger.Logger) (err error) {
Expand All @@ -51,48 +50,56 @@ func (t JobsBuiltinResponseTestCase) Run(asserter *logged_shell_asserter.LoggedS
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)
for i, expectedOutputEntry := range t.ExpectedOutputEntries {
expectedJobMarkerString := convertJobMarkerToString(expectedOutputEntry.Marker)

// This regex aims to match lines like: [1]+ Running sleep 5 &
regexString := fmt.Sprintf(
`^\[%d\]\s*%s\s+(?i)%s\s+(?-i)%s`,
outputEntry.JobNumber,
marker,
regexp.QuoteMeta(outputEntry.Status),
regexp.QuoteMeta(outputEntry.LaunchCommand),
expectedOutputEntry.JobNumber,
regexp.QuoteMeta(expectedJobMarkerString),
regexp.QuoteMeta(expectedOutputEntry.Status),
regexp.QuoteMeta(expectedOutputEntry.LaunchCommand),
)

// For 'running' jobs, bash displays the trailing & sign
// This is optional since ZSH doesn't use this
if strings.ToLower(outputEntry.Status) == "running" {
// 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 expectedOutputEntry.Status == "Running" {
regexString += "( &)?$"
} else {
regexString += "$"
}

regex := regexp.MustCompile(regexString)
expectedOutput := fmt.Sprintf(
"[%d]%s %s %s",
expectedOutputEntry.JobNumber, expectedJobMarkerString, expectedOutputEntry.Status, expectedOutputEntry.LaunchCommand,
)

// For 'Running' jobs, the trailing sign is expected
if expectedOutputEntry.Status == "Running" {
expectedOutput += " &"
}

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{regexp.MustCompile(regexString)},
})

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()
Expand All @@ -101,6 +108,7 @@ func (t JobsBuiltinResponseTestCase) Run(asserter *logged_shell_asserter.LoggedS
if err != nil {
return err
}

}

return nil
Expand Down
14 changes: 14 additions & 0 deletions internal/test_helpers/course_definition.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,20 @@ 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: "br6"
primary_extension_slug: "pipelines"
name: "Dual-command pipeline"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Debug = true

[tester::#DK5] Running tests for Stage #DK5 (dk5)
[tester::#DK5] Running ./your_program.sh
[your-program] $ sleep 100 &
[your-program] [1] 2518
[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 &"
Copy link
Member

@rohitpaulk rohitpaulk Feb 26, 2026

Choose a reason for hiding this comment

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

This is fine for now, but I can imagine a lot of confusion stemming from the usage of spaces here - if the user users a single space between each but has another mistake, it might look like we're complaining about spaces when we're complaining about something else.

Can't think of a neat way to solve this, but I think one way would be to use LLMs. We could expose this via the stage harness and find a mechanism to do restricted prompts (i.e. we have some guarantees over what kind of output they can emit). Then we could generate error messages like "missing space between - and Running"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Noted - Adding this to linear.

[your-program] $ 
[tester::#DK5] Assertion failed.
[tester::#DK5] Test failed
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Debug = true

[tester::#DK5] Running tests for Stage #DK5 (dk5)
[tester::#DK5] Running ./your_program.sh
[your-program] $ sleep 100 &
[your-program] [1] 2318
[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
Loading