1package bootstrap
2
3import (
4 "context"
5 "errors"
6 "io"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
14 "github.com/stretchr/testify/require"
15)
16
17type instanceRelayStub struct {
18 port.BrowserLaunchRelay
19 err error
20 prepared bool
21 lease io.Closer
22 deliverCalls int
23 deliverAfter int
24 deliveredKind string
25}
26
27func (r *instanceRelayStub) deliver(kind string) (bool, error) {
28 r.deliveredKind = kind
29 r.deliverCalls++
30 return r.deliverCalls >= r.deliverAfter, nil
31}
32
33func (r *instanceRelayStub) DeliverOpenExternalURL(context.Context, string) (bool, error) {
34 return r.deliver("external")
35}
36
37func (r *instanceRelayStub) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
38 return r.deliver("fresh")
39}
40
41func (r *instanceRelayStub) DeliverOpenInstance(context.Context, string, string) (bool, error) {
42 return r.deliver("instance")
43}
44
45func (r *instanceRelayStub) Prepare(lease io.Closer) error {
46 r.prepared = true
47 r.lease = lease
48 return r.err
49}
50func testInstanceProfile(t *testing.T) runtimeprofile.Profile {
51 t.Helper()
52 root := t.TempDir()
53 return runtimeprofile.Profile{InstanceRoot: root, IPC: runtimeprofile.IPCPaths{BrowserLaunchSocket: filepath.Join(root, "browser-launch.sock")}}
54}
55
56// testColdDefaultProfile mirrors main's default-profile path before
57// normalization: InstanceRoot is empty while IPC.RuntimeDir is populated.
58func testColdDefaultProfile(t *testing.T) runtimeprofile.Profile {
59 t.Helper()
60 runtimeDir := t.TempDir()
61 return runtimeprofile.Profile{IPC: runtimeprofile.IPCPaths{
62 RuntimeDir: runtimeDir,
63 BrowserLaunchSocket: filepath.Join(runtimeDir, "browser-launch.sock"),
64 }}
65}
66
67func TestRunWithInstanceRelayFailsBeforeEngineStartup(t *testing.T) {
68 failure := errors.New("bind failed")
69 relay := &instanceRelayStub{err: failure}
70 code, err := RunWithInstanceRelay(context.Background(), testInstanceProfile(t), relay, func() int { t.Fatal("must not run"); return 0 })
71 require.Equal(t, 1, code)
72 require.ErrorIs(t, err, failure)
73 require.True(t, relay.prepared)
74}
75func TestDeliverOrRunWithInstanceRelayPreservesLaunchIntent(t *testing.T) {
76 for _, test := range []struct {
77 name string
78 instance string
79 freshWindow bool
80 want string
81 }{
82 {name: "default external", want: "external"},
83 {name: "named instance", instance: "work", want: "instance"},
84 {name: "fresh window", instance: "work", freshWindow: true, want: "fresh"},
85 {name: "fresh window without instance", freshWindow: true, want: "fresh"},
86 } {
87 t.Run(test.name, func(t *testing.T) {
88 profile := testInstanceProfile(t)
89 owner, err := acquireInstanceNamespace(context.Background(), profile)
90 require.NoError(t, err)
91 defer owner.Close()
92 relay := &instanceRelayStub{deliverAfter: 1}
93 code, err := DeliverOrRunWithInstanceRelay(context.Background(), profile, relay, test.instance, "https://example.com", test.freshWindow, func() int {
94 t.Fatal("delivered request must not start host")
95 return 1
96 })
97 require.NoError(t, err)
98 require.Zero(t, code)
99 require.Equal(t, test.want, relay.deliveredKind)
100 })
101 }
102}
103
104func TestDeliverOrRunWithInstanceRelayRetriesWhileHostStarts(t *testing.T) {
105 profile := testInstanceProfile(t)
106 owner, err := acquireInstanceNamespace(context.Background(), profile)
107 require.NoError(t, err)
108 defer owner.Close()
109 relay := &instanceRelayStub{deliverAfter: 3}
110
111 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
112 defer cancel()
113 code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "work", "https://example.com", false, func() int {
114 t.Fatal("loser must not start host")
115 return 1
116 })
117 require.NoError(t, err)
118 require.Zero(t, code)
119 require.Equal(t, 3, relay.deliverCalls)
120}
121
122// With a cold default profile no host is running, so the first process must
123// normalize InstanceRoot to IPC.RuntimeDir, acquire the lease, prepare the
124// relay, and start the host instead of spinning on an empty runtime path. The
125// real default cold start carries an empty instance name (main passes
126// args.Instance verbatim), so delivery must use the external-URL path.
127func TestDeliverOrRunWithInstanceRelayStartsHostForColdDefaultProfile(t *testing.T) {
128 profile := testColdDefaultProfile(t)
129 relay := &instanceRelayStub{deliverAfter: 1 << 30}
130 ran := false
131
132 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
133 defer cancel()
134 code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "", "https://example.com", false, func() int {
135 ran = true
136 return 0
137 })
138 require.NoError(t, err)
139 require.Zero(t, code)
140 require.True(t, ran, "cold default profile must start the host")
141 require.True(t, relay.prepared, "host must prepare the relay")
142 require.Equal(t, 1, relay.deliverCalls)
143 require.Equal(t, "external", relay.deliveredKind, "empty instance name must not use the instance relay")
144}
145
146// A permanent acquisition failure (for example a runtime root that cannot be
147// created) must return immediately rather than retry until the context ends.
148func TestDeliverOrRunWithInstanceRelayReturnsPermanentAcquireError(t *testing.T) {
149 root := filepath.Join(t.TempDir(), "not-a-dir")
150 require.NoError(t, os.WriteFile(root, []byte("file"), 0o600))
151 profile := runtimeprofile.Profile{
152 InstanceRoot: root,
153 IPC: runtimeprofile.IPCPaths{BrowserLaunchSocket: filepath.Join(root, "browser-launch.sock")},
154 }
155 relay := &instanceRelayStub{deliverAfter: 1 << 30}
156
157 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
158 defer cancel()
159 start := time.Now()
160 code, err := DeliverOrRunWithInstanceRelay(ctx, profile, relay, "default", "https://example.com", false, func() int {
161 t.Fatal("permanent failure must not start the host")
162 return 1
163 })
164 elapsed := time.Since(start)
165 require.Error(t, err)
166 require.Equal(t, 1, code)
167 require.NotErrorIs(t, err, errInstanceNamespaceContended)
168 require.ErrorContains(t, err, "create named instance runtime directory")
169 require.Less(t, elapsed, instanceElectionSlice, "permanent failure must not retry")
170 require.Equal(t, 1, relay.deliverCalls, "delivery is attempted once before election")
171}
172
173func TestRunWithInstanceRelayOwnsLeaseThroughGUILifetime(t *testing.T) {
174 profile := testInstanceProfile(t)
175 relay := &instanceRelayStub{}
176 code, err := RunWithInstanceRelay(context.Background(), profile, relay, func() int {
177 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
178 defer cancel()
179 _, competitorErr := acquireInstanceNamespace(ctx, profile)
180 require.Error(t, competitorErr)
181 return 0
182 })
183 require.NoError(t, err)
184 require.Zero(t, code)
185 lease, err := acquireInstanceNamespace(context.Background(), profile)
186 require.NoError(t, err)
187 require.NoError(t, lease.Close())
188}