feat: define browser domain and application contracts
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"chub/internal/domain"
|
||||
)
|
||||
|
||||
type BrowserManager interface {
|
||||
Start(context.Context, domain.LaunchSpec) (domain.InstanceView, error)
|
||||
List(context.Context) ([]domain.InstanceView, error)
|
||||
Get(context.Context, string) (domain.InstanceView, error)
|
||||
Stop(context.Context, string, bool) error
|
||||
Restart(context.Context, string) (domain.InstanceView, error)
|
||||
}
|
||||
|
||||
type EventSink func(domain.BrowserEvent)
|
||||
|
||||
type ProcessHandle interface {
|
||||
PID() int
|
||||
Wait(context.Context) (int, error)
|
||||
}
|
||||
|
||||
type ProcessLauncher interface {
|
||||
Start(context.Context, domain.LaunchSpec) (ProcessHandle, error)
|
||||
}
|
||||
|
||||
type ExecutableResolver interface {
|
||||
Resolve(context.Context, domain.BrowserKind, string) (string, error)
|
||||
}
|
||||
|
||||
type ProcessIdentity struct {
|
||||
PID int
|
||||
Executable string
|
||||
CommandLine string
|
||||
UserDataDir string
|
||||
}
|
||||
|
||||
type ProcessInspector interface {
|
||||
Inspect(context.Context, int) (ProcessIdentity, error)
|
||||
Alive(context.Context, int) (bool, error)
|
||||
}
|
||||
|
||||
type ProfileUse struct {
|
||||
Occupied bool
|
||||
PID int
|
||||
Source string
|
||||
}
|
||||
|
||||
type ProfileInspector interface {
|
||||
Inspect(context.Context, string) (ProfileUse, error)
|
||||
}
|
||||
|
||||
type Clock func() time.Time
|
||||
@@ -0,0 +1,117 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BrowserKind string
|
||||
|
||||
const (
|
||||
BrowserChrome BrowserKind = "chrome"
|
||||
BrowserEdge BrowserKind = "edge"
|
||||
)
|
||||
|
||||
func (k BrowserKind) Valid() bool { return k == BrowserChrome || k == BrowserEdge }
|
||||
|
||||
type InstanceStatus string
|
||||
|
||||
const (
|
||||
StatusCreated InstanceStatus = "created"
|
||||
StatusStarting InstanceStatus = "starting"
|
||||
StatusRunning InstanceStatus = "running"
|
||||
StatusStopping InstanceStatus = "stopping"
|
||||
StatusExited InstanceStatus = "exited"
|
||||
StatusFailed InstanceStatus = "failed"
|
||||
StatusUnknown InstanceStatus = "unknown"
|
||||
)
|
||||
|
||||
type LaunchSpec struct {
|
||||
Kind BrowserKind
|
||||
Executable string
|
||||
UserDataDir string
|
||||
ProfileDirectory string
|
||||
TargetURL string
|
||||
ProxyServer string
|
||||
Headless bool
|
||||
ExtraArgs []string
|
||||
}
|
||||
|
||||
func (s LaunchSpec) Normalize() (LaunchSpec, error) {
|
||||
if !s.Kind.Valid() {
|
||||
return LaunchSpec{}, fmt.Errorf("%w: unsupported browser kind", ErrInvalidLaunchSpec)
|
||||
}
|
||||
userDataDir := strings.TrimSpace(s.UserDataDir)
|
||||
if userDataDir == "" || !filepath.IsAbs(userDataDir) {
|
||||
return LaunchSpec{}, fmt.Errorf("%w: user data dir must be absolute", ErrInvalidLaunchSpec)
|
||||
}
|
||||
target := strings.TrimSpace(s.TargetURL)
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return LaunchSpec{}, fmt.Errorf("%w: target URL must be absolute http or https", ErrInvalidLaunchSpec)
|
||||
}
|
||||
normalized := s
|
||||
normalized.Executable = strings.TrimSpace(s.Executable)
|
||||
normalized.UserDataDir = filepath.Clean(userDataDir)
|
||||
normalized.ProfileDirectory = strings.TrimSpace(s.ProfileDirectory)
|
||||
normalized.TargetURL = parsed.String()
|
||||
normalized.ProxyServer = strings.TrimSpace(s.ProxyServer)
|
||||
normalized.ExtraArgs = append([]string(nil), s.ExtraArgs...)
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
type InstanceView struct {
|
||||
ID string
|
||||
Kind BrowserKind
|
||||
Executable string
|
||||
PID int
|
||||
UserDataDir string
|
||||
Profile string
|
||||
TargetURL string
|
||||
Status InstanceStatus
|
||||
ExitCode *int
|
||||
StartedAt time.Time
|
||||
ChangedAt time.Time
|
||||
IdentityNote string
|
||||
}
|
||||
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
EventBrowserStarted EventKind = "browser.started"
|
||||
EventBrowserStatusChanged EventKind = "browser.status.changed"
|
||||
EventBrowserExited EventKind = "browser.exited"
|
||||
)
|
||||
|
||||
type BrowserEvent struct {
|
||||
Kind EventKind
|
||||
InstanceID string
|
||||
Status InstanceStatus
|
||||
PID int
|
||||
ExitCode *int
|
||||
ErrorCode ErrorCode
|
||||
At time.Time
|
||||
}
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorInvalidLaunchSpec ErrorCode = "invalid_launch_spec"
|
||||
ErrorProfileOccupied ErrorCode = "profile_occupied"
|
||||
ErrorExecutableNotFound ErrorCode = "executable_not_found"
|
||||
ErrorInstanceNotFound ErrorCode = "instance_not_found"
|
||||
ErrorIdentityMismatch ErrorCode = "identity_mismatch"
|
||||
ErrorProcessPermission ErrorCode = "process_permission"
|
||||
ErrorProcessStart ErrorCode = "process_start_failed"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidLaunchSpec = errors.New("invalid browser launch specification")
|
||||
ErrProfileOccupied = errors.New("browser user data dir is occupied")
|
||||
ErrInstanceNotFound = errors.New("browser instance was not found")
|
||||
ErrIdentityMismatch = errors.New("browser process identity mismatch")
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLaunchSpecNormalizeCanonicalizesSafeFields(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), "profile")
|
||||
normalized, err := (LaunchSpec{
|
||||
Kind: BrowserChrome,
|
||||
Executable: " chrome.exe ",
|
||||
UserDataDir: profile + string(filepath.Separator),
|
||||
ProfileDirectory: " Default ",
|
||||
TargetURL: " https://example.com/path ",
|
||||
ExtraArgs: []string{"--no-first-run"},
|
||||
}).Normalize()
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
if normalized.Executable != "chrome.exe" || normalized.UserDataDir != filepath.Clean(profile) || normalized.ProfileDirectory != "Default" || normalized.TargetURL != "https://example.com/path" {
|
||||
t.Fatalf("normalized spec = %#v", normalized)
|
||||
}
|
||||
normalized.ExtraArgs[0] = "changed"
|
||||
if normalized.ExtraArgs[0] == "" {
|
||||
t.Fatal("extra args unexpectedly empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchSpecNormalizeRejectsUnsafeValues(t *testing.T) {
|
||||
base := LaunchSpec{Kind: BrowserEdge, UserDataDir: filepath.Join(t.TempDir(), "profile"), TargetURL: "https://example.com"}
|
||||
cases := []LaunchSpec{
|
||||
{UserDataDir: base.UserDataDir, TargetURL: base.TargetURL},
|
||||
{Kind: base.Kind, UserDataDir: "relative", TargetURL: base.TargetURL},
|
||||
{Kind: base.Kind, UserDataDir: base.UserDataDir, TargetURL: "file:///tmp/test"},
|
||||
{Kind: base.Kind, UserDataDir: base.UserDataDir, TargetURL: "https://user@example.com"},
|
||||
}
|
||||
for i, spec := range cases {
|
||||
if _, err := spec.Normalize(); !errors.Is(err, ErrInvalidLaunchSpec) {
|
||||
t.Errorf("case %d error = %v, want ErrInvalidLaunchSpec", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user