runwork 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { pollForSession, runAsDetachedParent, isInternalDetachedChild, stripInternalChildFlag, INTERNAL_DETACHED_CHILD_FLAG, } from '../detach.js';
6
+ import { buildSessionFile, getSessionPaths, writeSessionFile, } from '../session.js';
7
+ function createTmpAppDir() {
8
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'runwork-detach-test-'));
9
+ }
10
+ function makeSessionFile(overrides = {}) {
11
+ return buildSessionFile({
12
+ pid: 11111,
13
+ sessionId: 'sess_test',
14
+ appId: 'test-app',
15
+ previewUrl: 'https://x.example',
16
+ cliVersion: '0.10.2',
17
+ mode: 'detached',
18
+ deps: { bootTime: () => 1_000_000_000_000 },
19
+ ...overrides,
20
+ });
21
+ }
22
+ describe('isInternalDetachedChild() / stripInternalChildFlag()', () => {
23
+ it('detects the internal marker', () => {
24
+ expect(isInternalDetachedChild(['runwork', 'dev', '--detach', INTERNAL_DETACHED_CHILD_FLAG])).toBe(true);
25
+ expect(isInternalDetachedChild(['runwork', 'dev', '--detach'])).toBe(false);
26
+ expect(isInternalDetachedChild(['runwork'])).toBe(false);
27
+ });
28
+ it('strips the marker but leaves other args alone', () => {
29
+ expect(stripInternalChildFlag([
30
+ 'dev', '--detach', INTERNAL_DETACHED_CHILD_FLAG, '--json',
31
+ ])).toEqual(['dev', '--detach', '--json']);
32
+ expect(stripInternalChildFlag(['dev', '--detach'])).toEqual(['dev', '--detach']);
33
+ });
34
+ });
35
+ describe('pollForSession()', () => {
36
+ let appDir;
37
+ beforeEach(() => {
38
+ appDir = createTmpAppDir();
39
+ });
40
+ afterEach(() => {
41
+ if (fs.existsSync(appDir)) {
42
+ fs.rmSync(appDir, { recursive: true, force: true });
43
+ }
44
+ });
45
+ it('returns "ready" immediately when the file is already present and matches', async () => {
46
+ const file = makeSessionFile({ pid: 12345 });
47
+ writeSessionFile(appDir, file);
48
+ const out = await pollForSession(appDir, 12345, 'test-app', {
49
+ intervalMs: 10,
50
+ timeoutMs: 1000,
51
+ });
52
+ expect(out.result).toBe('ready');
53
+ if (out.result === 'ready') {
54
+ expect(out.file).toEqual(file);
55
+ }
56
+ });
57
+ it('returns "ready" once the file appears mid-poll', async () => {
58
+ setTimeout(() => {
59
+ writeSessionFile(appDir, makeSessionFile({ pid: 22222 }));
60
+ }, 80);
61
+ const out = await pollForSession(appDir, 22222, 'test-app', {
62
+ intervalMs: 20,
63
+ timeoutMs: 1000,
64
+ });
65
+ expect(out.result).toBe('ready');
66
+ });
67
+ it('keeps polling past an intermediate empty-previewUrl write and resolves on the URL update', async () => {
68
+ // Simulate the child writing the file early without a URL -- the
69
+ // poller must NOT accept that as ready, and must wait for the URL
70
+ // update.
71
+ writeSessionFile(appDir, makeSessionFile({ pid: 33333, previewUrl: '' }));
72
+ setTimeout(() => {
73
+ writeSessionFile(appDir, makeSessionFile({ pid: 33333, previewUrl: 'https://late.example' }));
74
+ }, 100);
75
+ const out = await pollForSession(appDir, 33333, 'test-app', {
76
+ intervalMs: 20,
77
+ timeoutMs: 1000,
78
+ });
79
+ expect(out.result).toBe('ready');
80
+ if (out.result === 'ready') {
81
+ expect(out.file.previewUrl).toBe('https://late.example');
82
+ }
83
+ });
84
+ it('returns "wrong-pid" when a file appears with a different pid (race winner)', async () => {
85
+ writeSessionFile(appDir, makeSessionFile({ pid: 99999 }));
86
+ const out = await pollForSession(appDir, 12345, 'test-app', {
87
+ intervalMs: 10,
88
+ timeoutMs: 500,
89
+ });
90
+ expect(out.result).toBe('wrong-pid');
91
+ if (out.result === 'wrong-pid') {
92
+ expect(out.file.pid).toBe(99999);
93
+ }
94
+ });
95
+ it('keeps polling past a different-app file (foreign session file in the same dir)', async () => {
96
+ // Edge case: the file's appId doesn't match the one we expect. We
97
+ // ignore it (rather than treat as wrong-pid) so this kind of stale
98
+ // cross-app contamination doesn't accidentally short-circuit our
99
+ // detach handshake.
100
+ writeSessionFile(appDir, makeSessionFile({ pid: 99999, appId: 'other-app' }));
101
+ setTimeout(() => {
102
+ writeSessionFile(appDir, makeSessionFile({ pid: 12345, appId: 'test-app' }));
103
+ }, 80);
104
+ const out = await pollForSession(appDir, 12345, 'test-app', {
105
+ intervalMs: 20,
106
+ timeoutMs: 1000,
107
+ });
108
+ expect(out.result).toBe('ready');
109
+ if (out.result === 'ready') {
110
+ expect(out.file.pid).toBe(12345);
111
+ }
112
+ });
113
+ it('returns "timeout" when the file never appears', async () => {
114
+ const out = await pollForSession(appDir, 12345, 'test-app', {
115
+ intervalMs: 20,
116
+ timeoutMs: 100,
117
+ });
118
+ expect(out.result).toBe('timeout');
119
+ });
120
+ it('returns "child-exited" mid-poll when isChildAlive flips to false', async () => {
121
+ let alive = true;
122
+ setTimeout(() => { alive = false; }, 60);
123
+ const out = await pollForSession(appDir, 12345, 'test-app', {
124
+ intervalMs: 20,
125
+ timeoutMs: 1000,
126
+ isChildAlive: () => alive,
127
+ });
128
+ expect(out.result).toBe('child-exited');
129
+ });
130
+ it('catches a child that exited just at the deadline (final probe)', async () => {
131
+ // The child stays "alive" through the loop (no probe ever sees
132
+ // false), but exits in the gap between the last sleep and the
133
+ // deadline check. The trailing isChildAlive probe surfaces this
134
+ // as child-exited rather than a misleading timeout.
135
+ let probeCount = 0;
136
+ const out = await pollForSession(appDir, 12345, 'test-app', {
137
+ intervalMs: 20,
138
+ timeoutMs: 60,
139
+ isChildAlive: () => {
140
+ probeCount += 1;
141
+ // Only fail on the very last (post-loop) probe.
142
+ return probeCount < 100;
143
+ },
144
+ });
145
+ // We can hit either branch depending on probe ordering -- but with
146
+ // a finite timeout we should never see "ready" or "wrong-pid".
147
+ expect(['child-exited', 'timeout']).toContain(out.result);
148
+ });
149
+ });
150
+ describe('runAsDetachedParent()', () => {
151
+ let appDir;
152
+ beforeEach(() => {
153
+ appDir = createTmpAppDir();
154
+ });
155
+ afterEach(() => {
156
+ if (fs.existsSync(appDir)) {
157
+ fs.rmSync(appDir, { recursive: true, force: true });
158
+ }
159
+ });
160
+ it('orchestrates spawn -> poll -> ready when the mock child writes the file', async () => {
161
+ const childPid = 54321;
162
+ const spawn = vi.fn(() => {
163
+ // Schedule the "child" to write the session file shortly after spawn.
164
+ setTimeout(() => {
165
+ writeSessionFile(appDir, makeSessionFile({ pid: childPid, previewUrl: 'https://detached.example' }));
166
+ }, 50);
167
+ return {
168
+ pid: childPid,
169
+ kill: vi.fn(() => true),
170
+ isAlive: () => true,
171
+ };
172
+ });
173
+ const out = await runAsDetachedParent({
174
+ appDir,
175
+ expectedAppId: 'test-app',
176
+ childArgs: ['dev', '--detach', INTERNAL_DETACHED_CHILD_FLAG],
177
+ intervalMs: 20,
178
+ timeoutMs: 1000,
179
+ spawn,
180
+ });
181
+ expect(spawn).toHaveBeenCalledOnce();
182
+ expect(out.result).toBe('started');
183
+ if (out.result === 'started') {
184
+ expect(out.file.pid).toBe(childPid);
185
+ expect(out.file.previewUrl).toBe('https://detached.example');
186
+ }
187
+ });
188
+ it('on timeout: kills the child, removes our partial file, returns childLogTail when stderr exists', async () => {
189
+ const kill = vi.fn(() => true);
190
+ const spawn = vi.fn(() => {
191
+ // Pre-create a stderr log with a known tail so we can assert it's
192
+ // surfaced. The fake child does not write a session file.
193
+ const paths = getSessionPaths(appDir);
194
+ fs.mkdirSync(paths.dir, { recursive: true });
195
+ fs.writeFileSync(paths.stderrLog, 'fatal: sandbox failed to boot\n', 'utf-8');
196
+ return { pid: 12345, kill, isAlive: () => true };
197
+ });
198
+ const out = await runAsDetachedParent({
199
+ appDir,
200
+ expectedAppId: 'test-app',
201
+ childArgs: ['dev', '--detach', INTERNAL_DETACHED_CHILD_FLAG],
202
+ intervalMs: 20,
203
+ timeoutMs: 80,
204
+ spawn,
205
+ });
206
+ expect(out.result).toBe('timeout');
207
+ if (out.result === 'timeout') {
208
+ expect(out.ourPid).toBe(12345);
209
+ expect(out.childLogTail).toContain('fatal: sandbox failed to boot');
210
+ }
211
+ expect(kill).toHaveBeenCalledWith('SIGTERM');
212
+ });
213
+ it('on timeout: leaves another process\'s session file untouched (PID-aware cleanup)', async () => {
214
+ // Race: our child timed out, but in the meantime another process
215
+ // wrote its OWN session file with a different PID. We must NOT
216
+ // remove that file -- it belongs to the winner.
217
+ const winnerFile = makeSessionFile({ pid: 99999, previewUrl: 'https://winner.example' });
218
+ writeSessionFile(appDir, winnerFile);
219
+ const spawn = vi.fn(() => ({
220
+ pid: 12345,
221
+ kill: vi.fn(() => true),
222
+ // Pretend our child is alive (so we don't take the child-exited
223
+ // path) but write nothing -- triggering timeout. wrong-pid would
224
+ // also fire here since the file's appId matches; we use a
225
+ // different appId on the file to isolate the timeout path.
226
+ isAlive: () => true,
227
+ }));
228
+ // Overwrite with a foreign-app file so wrong-pid doesn't fire and
229
+ // we land in the timeout branch.
230
+ writeSessionFile(appDir, makeSessionFile({ pid: 99999, appId: 'other-app', previewUrl: 'https://other.example' }));
231
+ const out = await runAsDetachedParent({
232
+ appDir,
233
+ expectedAppId: 'test-app',
234
+ childArgs: [],
235
+ intervalMs: 10,
236
+ timeoutMs: 80,
237
+ spawn,
238
+ });
239
+ expect(out.result).toBe('timeout');
240
+ // The foreign file is still there.
241
+ const stillThere = fs.existsSync(getSessionPaths(appDir).file);
242
+ expect(stillThere).toBe(true);
243
+ });
244
+ it('returns "spawn-failed" when the spawn override throws', async () => {
245
+ const spawn = vi.fn(() => { throw new Error('execvp: ENOENT'); });
246
+ const out = await runAsDetachedParent({
247
+ appDir,
248
+ expectedAppId: 'test-app',
249
+ childArgs: [],
250
+ intervalMs: 10,
251
+ timeoutMs: 100,
252
+ spawn,
253
+ });
254
+ expect(out.result).toBe('spawn-failed');
255
+ });
256
+ it('returns "spawn-failed" when the child has no pid', async () => {
257
+ const spawn = vi.fn(() => ({ pid: 0, kill: vi.fn(() => true), isAlive: () => true }));
258
+ const out = await runAsDetachedParent({
259
+ appDir,
260
+ expectedAppId: 'test-app',
261
+ childArgs: [],
262
+ intervalMs: 10,
263
+ timeoutMs: 100,
264
+ spawn,
265
+ });
266
+ expect(out.result).toBe('spawn-failed');
267
+ });
268
+ it('returns "wrong-pid" AND kills the child when another process wins the write race', async () => {
269
+ // Codex review #2: leaving the loser child running would cause it
270
+ // to overwrite the winner's session file moments later. Killing
271
+ // the loser is the correct behavior.
272
+ const kill = vi.fn(() => true);
273
+ const spawn = vi.fn(() => {
274
+ setTimeout(() => {
275
+ writeSessionFile(appDir, makeSessionFile({ pid: 99999, previewUrl: 'https://winner.example' }));
276
+ }, 30);
277
+ return { pid: 12345, kill, isAlive: () => true };
278
+ });
279
+ const out = await runAsDetachedParent({
280
+ appDir,
281
+ expectedAppId: 'test-app',
282
+ childArgs: [],
283
+ intervalMs: 10,
284
+ timeoutMs: 1000,
285
+ spawn,
286
+ });
287
+ expect(out.result).toBe('wrong-pid');
288
+ if (out.result === 'wrong-pid') {
289
+ expect(out.ourPid).toBe(12345);
290
+ expect(out.file.pid).toBe(99999);
291
+ }
292
+ expect(kill).toHaveBeenCalledWith('SIGTERM');
293
+ });
294
+ it('returns "child-exited" promptly when the spawned child dies before writing the session file', async () => {
295
+ // Codex review #1: without an isAlive probe, a fast-failing child
296
+ // (missing auth, sync conflict, etc.) leaves the parent hanging
297
+ // for the full 90s timeout instead of surfacing the failure.
298
+ const paths = getSessionPaths(appDir);
299
+ fs.mkdirSync(paths.dir, { recursive: true });
300
+ fs.writeFileSync(paths.stderrLog, 'fatal: not authenticated\n', 'utf-8');
301
+ let alive = true;
302
+ const kill = vi.fn(() => true);
303
+ const spawn = vi.fn(() => {
304
+ // Simulate the child dying ~50ms after spawn (well before the
305
+ // 1000ms timeout), without ever writing a session file.
306
+ setTimeout(() => { alive = false; }, 50);
307
+ return { pid: 12345, kill, isAlive: () => alive };
308
+ });
309
+ const start = Date.now();
310
+ const out = await runAsDetachedParent({
311
+ appDir,
312
+ expectedAppId: 'test-app',
313
+ childArgs: [],
314
+ intervalMs: 20,
315
+ timeoutMs: 1000,
316
+ spawn,
317
+ });
318
+ const elapsed = Date.now() - start;
319
+ expect(out.result).toBe('child-exited');
320
+ if (out.result === 'child-exited') {
321
+ expect(out.ourPid).toBe(12345);
322
+ expect(out.childLogTail).toContain('fatal: not authenticated');
323
+ }
324
+ // The whole point of this fix: we should resolve far below the
325
+ // timeout, not at 1000ms. Allow a generous bound for CI noise.
326
+ expect(elapsed).toBeLessThan(500);
327
+ });
328
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { startPreviewUrlPoller } from '../preview-url-poller.js';
3
+ function makeClient(getDevStatus) {
4
+ return { getDevStatus };
5
+ }
6
+ describe('startPreviewUrlPoller', () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+ it('does not fire onChange when the URL stays the same', async () => {
14
+ const onChange = vi.fn();
15
+ const getDevStatus = vi.fn().mockResolvedValue({
16
+ previewUrl: 'https://stable.example',
17
+ sessionId: 's',
18
+ appId: 'a',
19
+ });
20
+ const poller = startPreviewUrlPoller({
21
+ client: makeClient(getDevStatus),
22
+ appId: 'a',
23
+ initialUrl: 'https://stable.example',
24
+ intervalMs: 1000,
25
+ onChange,
26
+ });
27
+ await vi.advanceTimersByTimeAsync(2500);
28
+ expect(getDevStatus).toHaveBeenCalled();
29
+ expect(onChange).not.toHaveBeenCalled();
30
+ expect(poller.getCurrent()).toBe('https://stable.example');
31
+ poller.stop();
32
+ });
33
+ it('fires onChange with previous URL and updates getCurrent() when the URL rotates', async () => {
34
+ const onChange = vi.fn();
35
+ const getDevStatus = vi.fn()
36
+ .mockResolvedValueOnce({ previewUrl: 'https://old.example', sessionId: 's', appId: 'a' })
37
+ .mockResolvedValue({ previewUrl: 'https://new.example', sessionId: 's', appId: 'a' });
38
+ const poller = startPreviewUrlPoller({
39
+ client: makeClient(getDevStatus),
40
+ appId: 'a',
41
+ initialUrl: 'https://old.example',
42
+ intervalMs: 1000,
43
+ onChange,
44
+ });
45
+ // First tick at t=1000 returns the unchanged URL: no onChange.
46
+ await vi.advanceTimersByTimeAsync(1500);
47
+ expect(onChange).not.toHaveBeenCalled();
48
+ expect(poller.getCurrent()).toBe('https://old.example');
49
+ // Second tick at t=2000 returns a new URL: onChange fires.
50
+ await vi.advanceTimersByTimeAsync(1000);
51
+ expect(onChange).toHaveBeenCalledTimes(1);
52
+ expect(onChange).toHaveBeenCalledWith('https://new.example', 'https://old.example');
53
+ expect(poller.getCurrent()).toBe('https://new.example');
54
+ poller.stop();
55
+ });
56
+ it('ignores transient empty URLs from the server (sandbox restart in flight)', async () => {
57
+ const onChange = vi.fn();
58
+ // Server briefly reports "" while replacing the sandbox; we should
59
+ // hold the last known good URL rather than flicker the status line.
60
+ const getDevStatus = vi.fn().mockResolvedValue({
61
+ previewUrl: '',
62
+ sessionId: 's',
63
+ appId: 'a',
64
+ });
65
+ const poller = startPreviewUrlPoller({
66
+ client: makeClient(getDevStatus),
67
+ appId: 'a',
68
+ initialUrl: 'https://stable.example',
69
+ intervalMs: 1000,
70
+ onChange,
71
+ });
72
+ await vi.advanceTimersByTimeAsync(3000);
73
+ expect(onChange).not.toHaveBeenCalled();
74
+ expect(poller.getCurrent()).toBe('https://stable.example');
75
+ poller.stop();
76
+ });
77
+ it('keeps polling after a failed fetch and reports the error', async () => {
78
+ const onChange = vi.fn();
79
+ const onError = vi.fn();
80
+ const getDevStatus = vi.fn()
81
+ .mockRejectedValueOnce(new Error('network down'))
82
+ .mockResolvedValue({ previewUrl: 'https://recovered.example', sessionId: 's', appId: 'a' });
83
+ const poller = startPreviewUrlPoller({
84
+ client: makeClient(getDevStatus),
85
+ appId: 'a',
86
+ initialUrl: 'https://old.example',
87
+ intervalMs: 1000,
88
+ onChange,
89
+ onError,
90
+ });
91
+ // First tick rejects.
92
+ await vi.advanceTimersByTimeAsync(1500);
93
+ expect(onError).toHaveBeenCalledTimes(1);
94
+ expect(onChange).not.toHaveBeenCalled();
95
+ expect(poller.getCurrent()).toBe('https://old.example');
96
+ // Second tick recovers.
97
+ await vi.advanceTimersByTimeAsync(1000);
98
+ expect(onChange).toHaveBeenCalledWith('https://recovered.example', 'https://old.example');
99
+ expect(poller.getCurrent()).toBe('https://recovered.example');
100
+ poller.stop();
101
+ });
102
+ it('stop() cancels future polls', async () => {
103
+ const onChange = vi.fn();
104
+ const getDevStatus = vi.fn().mockResolvedValue({
105
+ previewUrl: 'https://new.example',
106
+ sessionId: 's',
107
+ appId: 'a',
108
+ });
109
+ const poller = startPreviewUrlPoller({
110
+ client: makeClient(getDevStatus),
111
+ appId: 'a',
112
+ initialUrl: 'https://old.example',
113
+ intervalMs: 1000,
114
+ onChange,
115
+ });
116
+ poller.stop();
117
+ await vi.advanceTimersByTimeAsync(5000);
118
+ expect(getDevStatus).not.toHaveBeenCalled();
119
+ expect(onChange).not.toHaveBeenCalled();
120
+ });
121
+ it('skips overlapping ticks while a previous fetch is still in flight', async () => {
122
+ const onChange = vi.fn();
123
+ let resolveFirst;
124
+ const getDevStatus = vi.fn().mockImplementation(() => {
125
+ // First call returns a promise that we control manually; subsequent
126
+ // calls would resolve immediately, but the in-flight guard should
127
+ // prevent them from firing at all.
128
+ if (!resolveFirst) {
129
+ return new Promise((r) => { resolveFirst = r; });
130
+ }
131
+ return Promise.resolve({ previewUrl: 'https://later.example', sessionId: 's', appId: 'a' });
132
+ });
133
+ const poller = startPreviewUrlPoller({
134
+ client: makeClient(getDevStatus),
135
+ appId: 'a',
136
+ initialUrl: 'https://initial.example',
137
+ intervalMs: 1000,
138
+ onChange,
139
+ });
140
+ // Fire several intervals while the first fetch is still pending.
141
+ await vi.advanceTimersByTimeAsync(4500);
142
+ expect(getDevStatus).toHaveBeenCalledTimes(1);
143
+ // Resolve it and advance one more interval -- now the next tick fires.
144
+ resolveFirst({ previewUrl: 'https://initial.example', sessionId: 's', appId: 'a' });
145
+ await vi.advanceTimersByTimeAsync(1000);
146
+ expect(getDevStatus).toHaveBeenCalledTimes(2);
147
+ poller.stop();
148
+ });
149
+ });
@@ -0,0 +1 @@
1
+ export {};