@myerscarpenter/quest-dev 1.4.0 → 2.0.0
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.
- package/.claude/settings.local.json +7 -0
- package/.github/workflows/docs.yml +45 -0
- package/.github/workflows/publish.yml +11 -1
- package/README.md +27 -0
- package/build/cast/decoder.d.ts +48 -0
- package/build/cast/decoder.d.ts.map +1 -0
- package/build/cast/decoder.js +152 -0
- package/build/cast/decoder.js.map +1 -0
- package/build/cast/session.d.ts +87 -0
- package/build/cast/session.d.ts.map +1 -0
- package/build/cast/session.js +565 -0
- package/build/cast/session.js.map +1 -0
- package/build/commands/logcat.d.ts.map +1 -1
- package/build/commands/logcat.js +7 -6
- package/build/commands/logcat.js.map +1 -1
- package/build/commands/open.d.ts.map +1 -1
- package/build/commands/open.js +9 -4
- package/build/commands/open.js.map +1 -1
- package/build/commands/screenshot.d.ts.map +1 -1
- package/build/commands/screenshot.js +17 -20
- package/build/commands/screenshot.js.map +1 -1
- package/build/commands/stay-awake.d.ts +2 -15
- package/build/commands/stay-awake.d.ts.map +1 -1
- package/build/commands/stay-awake.js +14 -77
- package/build/commands/stay-awake.js.map +1 -1
- package/build/daemon/cast-manager.d.ts +42 -0
- package/build/daemon/cast-manager.d.ts.map +1 -0
- package/build/daemon/cast-manager.js +243 -0
- package/build/daemon/cast-manager.js.map +1 -0
- package/build/daemon/client.d.ts +40 -0
- package/build/daemon/client.d.ts.map +1 -0
- package/build/daemon/client.js +133 -0
- package/build/daemon/client.js.map +1 -0
- package/build/daemon/daemon.d.ts +20 -0
- package/build/daemon/daemon.d.ts.map +1 -0
- package/build/daemon/daemon.js +130 -0
- package/build/daemon/daemon.js.map +1 -0
- package/build/daemon/deploy.d.ts +44 -0
- package/build/daemon/deploy.d.ts.map +1 -0
- package/build/daemon/deploy.js +230 -0
- package/build/daemon/deploy.js.map +1 -0
- package/build/daemon/logcat-manager.d.ts +39 -0
- package/build/daemon/logcat-manager.d.ts.map +1 -0
- package/build/daemon/logcat-manager.js +194 -0
- package/build/daemon/logcat-manager.js.map +1 -0
- package/build/daemon/server.d.ts +19 -0
- package/build/daemon/server.d.ts.map +1 -0
- package/build/daemon/server.js +482 -0
- package/build/daemon/server.js.map +1 -0
- package/build/daemon/stay-awake-manager.d.ts +22 -0
- package/build/daemon/stay-awake-manager.d.ts.map +1 -0
- package/build/daemon/stay-awake-manager.js +74 -0
- package/build/daemon/stay-awake-manager.js.map +1 -0
- package/build/index.js +285 -45
- package/build/index.js.map +1 -1
- package/build/public/dashboard.js +749 -0
- package/build/public/index.html +12 -0
- package/build/public/style.css +106 -0
- package/build/utils/adb.d.ts +12 -0
- package/build/utils/adb.d.ts.map +1 -1
- package/build/utils/adb.js +116 -51
- package/build/utils/adb.js.map +1 -1
- package/build/utils/casting-apk.d.ts +40 -0
- package/build/utils/casting-apk.d.ts.map +1 -0
- package/build/utils/casting-apk.js +252 -0
- package/build/utils/casting-apk.js.map +1 -0
- package/build/utils/config.d.ts +5 -3
- package/build/utils/config.d.ts.map +1 -1
- package/build/utils/config.js +18 -38
- package/build/utils/config.js.map +1 -1
- package/build/utils/exec.d.ts +5 -0
- package/build/utils/exec.d.ts.map +1 -1
- package/build/utils/exec.js +17 -0
- package/build/utils/exec.js.map +1 -1
- package/build/utils/filename.d.ts +7 -1
- package/build/utils/filename.d.ts.map +1 -1
- package/build/utils/filename.js +17 -2
- package/build/utils/filename.js.map +1 -1
- package/build/utils/filename.test.js +33 -1
- package/build/utils/filename.test.js.map +1 -1
- package/build/utils/jpeg-comment.d.ts +14 -0
- package/build/utils/jpeg-comment.d.ts.map +1 -0
- package/build/utils/jpeg-comment.js +28 -0
- package/build/utils/jpeg-comment.js.map +1 -0
- package/build/utils/test-properties.d.ts +34 -0
- package/build/utils/test-properties.d.ts.map +1 -0
- package/build/utils/test-properties.js +73 -0
- package/build/utils/test-properties.js.map +1 -0
- package/build/utils/verbose.d.ts +3 -0
- package/build/utils/verbose.d.ts.map +1 -0
- package/build/utils/verbose.js +13 -0
- package/build/utils/verbose.js.map +1 -0
- package/package.json +11 -5
- package/packages/cast2-protocol/README.md +86 -0
- package/packages/cast2-protocol/docs/_config.yml +4 -0
- package/packages/cast2-protocol/docs/feature-flags.md +102 -0
- package/packages/cast2-protocol/docs/index.md +24 -0
- package/packages/cast2-protocol/docs/open-investigations.md +149 -0
- package/packages/cast2-protocol/docs/protocol.md +602 -0
- package/packages/cast2-protocol/package.json +46 -0
- package/packages/cast2-protocol/src/constants.ts +65 -0
- package/packages/cast2-protocol/src/index.ts +7 -0
- package/packages/cast2-protocol/src/mgik.ts +69 -0
- package/packages/cast2-protocol/src/mud.ts +294 -0
- package/packages/cast2-protocol/src/pose.ts +99 -0
- package/packages/cast2-protocol/src/resolutions.ts +34 -0
- package/packages/cast2-protocol/src/types.ts +64 -0
- package/packages/cast2-protocol/src/xrsp.ts +73 -0
- package/packages/cast2-protocol/tests/mgik.test.ts +80 -0
- package/packages/cast2-protocol/tests/mud.test.ts +295 -0
- package/packages/cast2-protocol/tests/pose.test.ts +173 -0
- package/packages/cast2-protocol/tests/xrsp.test.ts +90 -0
- package/packages/cast2-protocol/tsconfig.json +20 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/cast/decoder.ts +178 -0
- package/src/cast/session.ts +708 -0
- package/src/commands/logcat.ts +6 -5
- package/src/commands/open.ts +10 -3
- package/src/commands/screenshot.ts +19 -13
- package/src/commands/stay-awake.ts +22 -91
- package/src/daemon/adbkit-apkreader.d.ts +14 -0
- package/src/daemon/cast-manager.ts +282 -0
- package/src/daemon/client.ts +166 -0
- package/src/daemon/daemon.ts +169 -0
- package/src/daemon/deploy.ts +307 -0
- package/src/daemon/logcat-manager.ts +229 -0
- package/src/daemon/server.ts +595 -0
- package/src/daemon/stay-awake-manager.ts +83 -0
- package/src/index.ts +340 -56
- package/src/public/dashboard.js +288 -0
- package/src/public/index.html +12 -0
- package/src/public/style.css +106 -0
- package/src/utils/adb.ts +129 -42
- package/src/utils/casting-apk.ts +276 -0
- package/src/utils/config.ts +18 -36
- package/src/utils/exec.ts +20 -0
- package/src/utils/filename.test.ts +41 -1
- package/src/utils/filename.ts +18 -2
- package/src/utils/jpeg-comment.ts +30 -0
- package/src/utils/test-properties.ts +94 -0
- package/src/utils/verbose.ts +14 -0
- package/tests/cast/auto-layer.test.ts +87 -0
- package/tests/cast/decoder.test.ts +82 -0
- package/tests/cast/session-restart.test.ts +107 -0
- package/tests/config.test.ts +17 -22
- package/tests/daemon/api-status.test.ts +82 -0
- package/tests/daemon/cast-manager.test.ts +69 -0
- package/tests/daemon/mjpeg-stream.test.ts +144 -0
- package/tests/daemon/pose-endpoint.test.ts +63 -0
- package/tests/daemon/start-guard.test.ts +77 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ServerResponse } from "node:http";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal extraction of CastManager.broadcast() logic for unit testing
|
|
6
|
+
* without needing to instantiate the full CastManager (which requires ADB).
|
|
7
|
+
*/
|
|
8
|
+
function broadcast(
|
|
9
|
+
sseClients: Set<ServerResponse>,
|
|
10
|
+
event: string,
|
|
11
|
+
data: unknown,
|
|
12
|
+
): void {
|
|
13
|
+
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
14
|
+
for (const res of sseClients) {
|
|
15
|
+
try {
|
|
16
|
+
res.write(msg);
|
|
17
|
+
} catch {
|
|
18
|
+
sseClients.delete(res);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeFakeRes(opts?: { throwOnWrite?: boolean }): ServerResponse {
|
|
24
|
+
return {
|
|
25
|
+
write: opts?.throwOnWrite
|
|
26
|
+
? () => { throw new Error("socket destroyed"); }
|
|
27
|
+
: vi.fn(),
|
|
28
|
+
} as unknown as ServerResponse;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("SSE broadcast", () => {
|
|
32
|
+
it("writes to all live clients", () => {
|
|
33
|
+
const clients = new Set<ServerResponse>();
|
|
34
|
+
const a = makeFakeRes();
|
|
35
|
+
const b = makeFakeRes();
|
|
36
|
+
clients.add(a);
|
|
37
|
+
clients.add(b);
|
|
38
|
+
|
|
39
|
+
broadcast(clients, "status", { ok: true });
|
|
40
|
+
|
|
41
|
+
expect(a.write).toHaveBeenCalledOnce();
|
|
42
|
+
expect(b.write).toHaveBeenCalledOnce();
|
|
43
|
+
expect(clients.size).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("removes dead clients on write error", () => {
|
|
47
|
+
const clients = new Set<ServerResponse>();
|
|
48
|
+
const alive = makeFakeRes();
|
|
49
|
+
const dead = makeFakeRes({ throwOnWrite: true });
|
|
50
|
+
clients.add(alive);
|
|
51
|
+
clients.add(dead);
|
|
52
|
+
|
|
53
|
+
broadcast(clients, "status", { ok: true });
|
|
54
|
+
|
|
55
|
+
expect(alive.write).toHaveBeenCalledOnce();
|
|
56
|
+
expect(clients.size).toBe(1);
|
|
57
|
+
expect(clients.has(alive)).toBe(true);
|
|
58
|
+
expect(clients.has(dead)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("handles all clients dead", () => {
|
|
62
|
+
const clients = new Set<ServerResponse>();
|
|
63
|
+
clients.add(makeFakeRes({ throwOnWrite: true }));
|
|
64
|
+
clients.add(makeFakeRes({ throwOnWrite: true }));
|
|
65
|
+
|
|
66
|
+
expect(() => broadcast(clients, "status", {})).not.toThrow();
|
|
67
|
+
expect(clients.size).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MJPEG stream cleanup logic.
|
|
5
|
+
* Bug: interval can fire after reply.raw.end() if session stops
|
|
6
|
+
* and client hasn't disconnected yet.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface FakeResponse {
|
|
10
|
+
writeHead: ReturnType<typeof vi.fn>;
|
|
11
|
+
write: ReturnType<typeof vi.fn>;
|
|
12
|
+
end: ReturnType<typeof vi.fn>;
|
|
13
|
+
ended: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createFakeResponse(): FakeResponse {
|
|
17
|
+
const res: FakeResponse = {
|
|
18
|
+
writeHead: vi.fn(),
|
|
19
|
+
write: vi.fn(),
|
|
20
|
+
end: vi.fn(() => { res.ended = true; }),
|
|
21
|
+
ended: false,
|
|
22
|
+
};
|
|
23
|
+
return res;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fixed MJPEG stream loop: checks `ended` before writing,
|
|
28
|
+
* and wraps writes in try-catch.
|
|
29
|
+
*/
|
|
30
|
+
function startMjpegStream(
|
|
31
|
+
res: FakeResponse,
|
|
32
|
+
getSession: () => { running: boolean; getScreenshot: () => Buffer | null } | null,
|
|
33
|
+
onCleanup: () => void,
|
|
34
|
+
): { interval: ReturnType<typeof setInterval>; cleanup: () => void } {
|
|
35
|
+
let cleaned = false;
|
|
36
|
+
const cleanup = () => {
|
|
37
|
+
if (cleaned) return;
|
|
38
|
+
cleaned = true;
|
|
39
|
+
clearInterval(interval);
|
|
40
|
+
try { res.end(); } catch { /* ignore */ }
|
|
41
|
+
onCleanup();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const interval = setInterval(() => {
|
|
45
|
+
const s = getSession();
|
|
46
|
+
if (!s?.running) {
|
|
47
|
+
cleanup();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (res.ended) {
|
|
51
|
+
cleanup();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const jpeg = s.getScreenshot();
|
|
55
|
+
if (jpeg) {
|
|
56
|
+
try {
|
|
57
|
+
res.write(`--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`);
|
|
58
|
+
res.write(jpeg);
|
|
59
|
+
res.write("\r\n");
|
|
60
|
+
} catch {
|
|
61
|
+
cleanup();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}, 50);
|
|
65
|
+
|
|
66
|
+
return { interval, cleanup };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("MJPEG stream cleanup", () => {
|
|
70
|
+
beforeEach(() => { vi.useFakeTimers(); });
|
|
71
|
+
afterEach(() => { vi.useRealTimers(); });
|
|
72
|
+
|
|
73
|
+
it("writes frames while session is running", () => {
|
|
74
|
+
const res = createFakeResponse();
|
|
75
|
+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
|
|
76
|
+
const session = { running: true, getScreenshot: () => jpeg };
|
|
77
|
+
const cleanupFn = vi.fn();
|
|
78
|
+
|
|
79
|
+
startMjpegStream(res, () => session, cleanupFn);
|
|
80
|
+
vi.advanceTimersByTime(100);
|
|
81
|
+
|
|
82
|
+
expect(res.write).toHaveBeenCalled();
|
|
83
|
+
expect(cleanupFn).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("cleans up when session stops", () => {
|
|
87
|
+
const res = createFakeResponse();
|
|
88
|
+
const session = { running: true, getScreenshot: () => Buffer.from([1]) };
|
|
89
|
+
const cleanupFn = vi.fn();
|
|
90
|
+
|
|
91
|
+
startMjpegStream(res, () => session, cleanupFn);
|
|
92
|
+
vi.advanceTimersByTime(50);
|
|
93
|
+
|
|
94
|
+
session.running = false;
|
|
95
|
+
vi.advanceTimersByTime(100);
|
|
96
|
+
|
|
97
|
+
expect(res.end).toHaveBeenCalled();
|
|
98
|
+
expect(cleanupFn).toHaveBeenCalledOnce();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not write after cleanup", () => {
|
|
102
|
+
const res = createFakeResponse();
|
|
103
|
+
const session = { running: true, getScreenshot: () => Buffer.from([1]) };
|
|
104
|
+
const cleanupFn = vi.fn();
|
|
105
|
+
|
|
106
|
+
const { cleanup } = startMjpegStream(res, () => session, cleanupFn);
|
|
107
|
+
|
|
108
|
+
// Simulate client disconnect
|
|
109
|
+
cleanup();
|
|
110
|
+
res.write.mockClear();
|
|
111
|
+
|
|
112
|
+
vi.advanceTimersByTime(200);
|
|
113
|
+
|
|
114
|
+
// No more writes after cleanup
|
|
115
|
+
expect(res.write).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("cleanup is idempotent", () => {
|
|
119
|
+
const res = createFakeResponse();
|
|
120
|
+
const session = { running: true, getScreenshot: () => Buffer.from([1]) };
|
|
121
|
+
const cleanupFn = vi.fn();
|
|
122
|
+
|
|
123
|
+
const { cleanup } = startMjpegStream(res, () => session, cleanupFn);
|
|
124
|
+
|
|
125
|
+
cleanup();
|
|
126
|
+
cleanup();
|
|
127
|
+
cleanup();
|
|
128
|
+
|
|
129
|
+
expect(res.end).toHaveBeenCalledOnce();
|
|
130
|
+
expect(cleanupFn).toHaveBeenCalledOnce();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("handles write errors gracefully", () => {
|
|
134
|
+
const res = createFakeResponse();
|
|
135
|
+
res.write.mockImplementation(() => { throw new Error("broken pipe"); });
|
|
136
|
+
const session = { running: true, getScreenshot: () => Buffer.from([1]) };
|
|
137
|
+
const cleanupFn = vi.fn();
|
|
138
|
+
|
|
139
|
+
startMjpegStream(res, () => session, cleanupFn);
|
|
140
|
+
vi.advanceTimersByTime(100);
|
|
141
|
+
|
|
142
|
+
expect(cleanupFn).toHaveBeenCalledOnce();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createPoseState, updatePose, setPoseOffset, type PoseState } from "@myerscarpenter/cast2-protocol";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Simulates the /cast/pose endpoint logic:
|
|
6
|
+
* offset and delta should be mutually exclusive.
|
|
7
|
+
*/
|
|
8
|
+
function applyPoseRequest(
|
|
9
|
+
state: PoseState,
|
|
10
|
+
data: Record<string, number>,
|
|
11
|
+
): PoseState {
|
|
12
|
+
// Direct offset — takes priority
|
|
13
|
+
if ("x" in data || "y" in data || "z" in data || "yaw" in data || "pitch" in data) {
|
|
14
|
+
return setPoseOffset(state, {
|
|
15
|
+
x: data.x,
|
|
16
|
+
y: data.y,
|
|
17
|
+
z: data.z,
|
|
18
|
+
yaw: data.yaw,
|
|
19
|
+
pitch: data.pitch,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// Incremental deltas — only if no offset fields present
|
|
23
|
+
if ("dx" in data || "dy" in data || "dz" in data || "d_yaw" in data || "d_pitch" in data) {
|
|
24
|
+
return updatePose(state, {
|
|
25
|
+
dForward: data.dz ?? 0,
|
|
26
|
+
dStrafe: data.dx ?? 0,
|
|
27
|
+
dUp: data.dy ?? 0,
|
|
28
|
+
dYaw: data.d_yaw ?? 0,
|
|
29
|
+
dPitch: data.d_pitch ?? 0,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return state;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("/cast/pose endpoint logic", () => {
|
|
36
|
+
it("applies offset when offset fields provided", () => {
|
|
37
|
+
const state = createPoseState();
|
|
38
|
+
const result = applyPoseRequest(state, { x: 1.0, y: 2.0 });
|
|
39
|
+
expect(result.x).toBeCloseTo(1.0);
|
|
40
|
+
expect(result.y).toBeCloseTo(2.0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("applies delta when delta fields provided", () => {
|
|
44
|
+
const state = createPoseState();
|
|
45
|
+
const result = applyPoseRequest(state, { dz: 0.3 }); // forward
|
|
46
|
+
expect(result.z).toBeCloseTo(-0.3); // OpenXR: forward = -Z
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not double-apply when both offset and delta provided", () => {
|
|
50
|
+
const state = createPoseState();
|
|
51
|
+
// If both are present, offset wins — delta is NOT also applied
|
|
52
|
+
const result = applyPoseRequest(state, { x: 1.0, dz: 0.3 });
|
|
53
|
+
expect(result.x).toBeCloseTo(1.0);
|
|
54
|
+
// z should be 0 (offset only set x, delta was ignored)
|
|
55
|
+
expect(result.z).toBeCloseTo(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns unchanged state when no recognized fields", () => {
|
|
59
|
+
const state = createPoseState();
|
|
60
|
+
const result = applyPoseRequest(state, { foo: 42 } as any);
|
|
61
|
+
expect(result).toBe(state);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bug 1: Stats interval leaked on start failure.
|
|
5
|
+
* If session.bind() throws after statsInterval is created,
|
|
6
|
+
* the interval is never cleared.
|
|
7
|
+
*
|
|
8
|
+
* Bug 2: Concurrent /cast/start calls can both pass the
|
|
9
|
+
* isActive check and create two sessions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
describe("stats interval cleanup on start failure", () => {
|
|
13
|
+
it("interval is cleared if setup fails after creation", async () => {
|
|
14
|
+
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
15
|
+
let intervalCleared = false;
|
|
16
|
+
|
|
17
|
+
// Simulate the fixed pattern: create interval only AFTER all async ops succeed
|
|
18
|
+
async function startFixed(shouldFail: boolean) {
|
|
19
|
+
// Do all async work first
|
|
20
|
+
if (shouldFail) throw new Error("bind failed");
|
|
21
|
+
|
|
22
|
+
// Only create interval after success
|
|
23
|
+
intervalId = setInterval(() => {}, 500);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Start should fail
|
|
27
|
+
await expect(startFixed(true)).rejects.toThrow("bind failed");
|
|
28
|
+
|
|
29
|
+
// Interval was never created
|
|
30
|
+
expect(intervalId).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("buggy pattern: interval created before async work leaks", async () => {
|
|
34
|
+
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
|
|
36
|
+
async function startBuggy(shouldFail: boolean) {
|
|
37
|
+
// Bug: interval created BEFORE async work
|
|
38
|
+
intervalId = setInterval(() => {}, 500);
|
|
39
|
+
|
|
40
|
+
// Async work that might fail
|
|
41
|
+
if (shouldFail) throw new Error("bind failed");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await expect(startBuggy(true)).rejects.toThrow("bind failed");
|
|
45
|
+
|
|
46
|
+
// Interval was created and is now leaked!
|
|
47
|
+
expect(intervalId).not.toBeNull();
|
|
48
|
+
clearInterval(intervalId!); // clean up for test
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("concurrent start guard", () => {
|
|
53
|
+
it("second concurrent start is rejected while first is in progress", async () => {
|
|
54
|
+
let starting = false;
|
|
55
|
+
let startCount = 0;
|
|
56
|
+
|
|
57
|
+
async function startGuarded() {
|
|
58
|
+
if (starting) throw new Error("start already in progress");
|
|
59
|
+
starting = true;
|
|
60
|
+
try {
|
|
61
|
+
startCount++;
|
|
62
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
63
|
+
} finally {
|
|
64
|
+
starting = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const p1 = startGuarded();
|
|
69
|
+
const p2 = startGuarded().catch((e) => e);
|
|
70
|
+
|
|
71
|
+
await p1;
|
|
72
|
+
const err = await p2;
|
|
73
|
+
expect(err).toBeInstanceOf(Error);
|
|
74
|
+
expect(err.message).toBe("start already in progress");
|
|
75
|
+
expect(startCount).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/vitest.config.ts
ADDED