package browser import ( "context" "errors" "path/filepath" "sync" "testing" "time" "chub/internal/domain" ) func TestLauncherSeparatesProfilesBlocksDuplicatesAndReleasesAfterExit(t *testing.T) { runner := &fakeRunner{} launcher := NewLauncher(runner) firstProfile := filepath.Join(t.TempDir(), "one") secondProfile := filepath.Join(t.TempDir(), "two") first, err := launcher.Start(context.Background(), launchSpec(firstProfile)) if err != nil { t.Fatalf("first Start() error = %v", err) } second, err := launcher.Start(context.Background(), launchSpec(secondProfile)) if err != nil { t.Fatalf("second Start() error = %v", err) } if first.PID() == second.PID() { t.Fatalf("separate profiles shared PID %d", first.PID()) } if _, err := launcher.Start(context.Background(), launchSpec(firstProfile)); !errors.Is(err, domain.ErrProfileOccupied) { t.Fatalf("duplicate Start() error = %v", err) } use, err := launcher.InspectProfile(context.Background(), firstProfile) if err != nil || !use.Occupied || use.PID != first.PID() || use.Source != "chub_registry" { t.Fatalf("profile use = %+v, error = %v", use, err) } runner.processAt(0).finish(0, nil) if code, err := first.Wait(context.Background()); err != nil || code != 0 { t.Fatalf("Wait() = %d, %v", code, err) } for i := 0; i < 50; i++ { use, err = launcher.InspectProfile(context.Background(), firstProfile) if err == nil && !use.Occupied { break } time.Sleep(time.Millisecond) } if use.Occupied { t.Fatalf("profile remained occupied after exit: %+v", use) } runner.processAt(1).finish(0, nil) } func TestProcessWaitCancellationDoesNotTerminateBrowser(t *testing.T) { runner := &fakeRunner{} launcher := NewLauncher(runner) handle, err := launcher.Start(context.Background(), launchSpec(filepath.Join(t.TempDir(), "profile"))) if err != nil { t.Fatalf("Start() error = %v", err) } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := handle.Wait(ctx); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled Wait() error = %v", err) } if runner.processAt(0).wasFinished() { t.Fatal("cancelled Wait terminated browser") } runner.processAt(0).finish(23, nil) if code, err := handle.Wait(context.Background()); err != nil || code != 23 { t.Fatalf("completed Wait() = %d, %v", code, err) } } func TestLauncherStopProfileTargetsOnlyRegisteredProfile(t *testing.T) { runner := &fakeRunner{} launcher := NewLauncher(runner) profile := filepath.Join(t.TempDir(), "profile") if _, err := launcher.Start(context.Background(), launchSpec(profile)); err != nil { t.Fatalf("Start() error = %v", err) } if err := launcher.StopProfile(context.Background(), profile, false); err != nil { t.Fatalf("StopProfile() error = %v", err) } if runner.processAt(0).stopCount != 1 || runner.processAt(0).stopForce { t.Fatalf("stop state = count %d force %v", runner.processAt(0).stopCount, runner.processAt(0).stopForce) } if err := launcher.StopProfile(context.Background(), filepath.Join(t.TempDir(), "other"), true); !errors.Is(err, domain.ErrInstanceNotFound) { t.Fatalf("unknown profile error = %v", err) } } func launchSpec(profile string) domain.LaunchSpec { return domain.LaunchSpec{Kind: domain.BrowserChrome, Executable: `C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe`, UserDataDir: profile, TargetURL: "https://example.com"} } type fakeRunner struct { mu sync.Mutex processes []*fakeProcess } func (r *fakeRunner) Start(_ string, _ []string) (RunnerProcess, error) { r.mu.Lock() defer r.mu.Unlock() p := newFakeProcess(4100 + len(r.processes)) r.processes = append(r.processes, p) return p, nil } func (r *fakeRunner) processAt(index int) *fakeProcess { r.mu.Lock() defer r.mu.Unlock() return r.processes[index] } type fakeProcess struct { pid int done chan processResult mu sync.Mutex finished bool stopCount int stopForce bool } type processResult struct { code int err error } func newFakeProcess(pid int) *fakeProcess { return &fakeProcess{pid: pid, done: make(chan processResult, 1)} } func (p *fakeProcess) PID() int { return p.pid } func (p *fakeProcess) Wait() (int, error) { result := <-p.done; return result.code, result.err } func (p *fakeProcess) Stop(_ context.Context, force bool) error { p.mu.Lock() defer p.mu.Unlock() p.stopCount++ p.stopForce = force return nil } func (p *fakeProcess) finish(code int, err error) { p.mu.Lock() defer p.mu.Unlock() if p.finished { return } p.finished = true p.done <- processResult{code: code, err: err} } func (p *fakeProcess) wasFinished() bool { p.mu.Lock(); defer p.mu.Unlock(); return p.finished }