@autifyhq/aximo-cli 0.14.0 → 0.16.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/README.md +24 -1
- package/dist/commands/device/device-config.d.ts +2 -2
- package/dist/commands/device/device-config.js +5 -8
- package/dist/commands/device/index.js +2 -1
- package/dist/commands/device/paths.d.ts +4 -0
- package/dist/commands/device/paths.js +12 -0
- package/dist/commands/device/reset.d.ts +1 -0
- package/dist/commands/device/reset.js +18 -3
- package/dist/commands/device/reset.test.js +13 -0
- package/dist/commands/device/testbox/box.d.ts +90 -0
- package/dist/commands/device/testbox/box.js +379 -0
- package/dist/commands/device/testbox/box.test.d.ts +1 -0
- package/dist/commands/device/testbox/box.test.js +815 -0
- package/dist/commands/device/testbox/container.d.ts +58 -0
- package/dist/commands/device/testbox/container.js +289 -0
- package/dist/commands/device/testbox/docker.d.ts +31 -0
- package/dist/commands/device/testbox/docker.js +78 -0
- package/dist/commands/device/testbox/docker.test.d.ts +1 -0
- package/dist/commands/device/testbox/docker.test.js +82 -0
- package/dist/commands/device/testbox/down.d.ts +7 -0
- package/dist/commands/device/testbox/down.js +14 -0
- package/dist/commands/device/testbox/errors.d.ts +4 -0
- package/dist/commands/device/testbox/errors.js +7 -0
- package/dist/commands/device/testbox/fake-docker.d.ts +25 -0
- package/dist/commands/device/testbox/fake-docker.js +35 -0
- package/dist/commands/device/testbox/index.d.ts +2 -0
- package/dist/commands/device/testbox/index.js +16 -0
- package/dist/commands/device/testbox/logs.d.ts +15 -0
- package/dist/commands/device/testbox/logs.js +43 -0
- package/dist/commands/device/testbox/prune.d.ts +11 -0
- package/dist/commands/device/testbox/prune.js +17 -0
- package/dist/commands/device/testbox/registration.d.ts +9 -0
- package/dist/commands/device/testbox/registration.js +56 -0
- package/dist/commands/device/testbox/reset.d.ts +7 -0
- package/dist/commands/device/testbox/reset.js +14 -0
- package/dist/commands/device/testbox/result.d.ts +8 -0
- package/dist/commands/device/testbox/result.js +23 -0
- package/dist/commands/device/testbox/result.test.d.ts +1 -0
- package/dist/commands/device/testbox/result.test.js +34 -0
- package/dist/commands/device/testbox/self-command.d.ts +12 -0
- package/dist/commands/device/testbox/self-command.js +36 -0
- package/dist/commands/device/testbox/self-command.test.d.ts +1 -0
- package/dist/commands/device/testbox/self-command.test.js +58 -0
- package/dist/commands/device/testbox/status.d.ts +7 -0
- package/dist/commands/device/testbox/status.js +11 -0
- package/dist/commands/device/testbox/up.d.ts +47 -0
- package/dist/commands/device/testbox/up.js +76 -0
- package/dist/commands/device/testbox/up.test.d.ts +1 -0
- package/dist/commands/device/testbox/up.test.js +52 -0
- package/dist/commands/device/testbox/url.d.ts +7 -0
- package/dist/commands/device/testbox/url.js +20 -0
- package/dist/commands/device/user-devices.d.ts +4 -0
- package/dist/commands/device/user-devices.js +18 -0
- package/dist/commands/mcp/index.js +6 -3
- package/dist/commands/mcp/server.d.ts +2 -2
- package/dist/commands/mcp/server.test.js +28 -1
- package/dist/commands/mcp/testbox-tools.test.d.ts +1 -0
- package/dist/commands/mcp/testbox-tools.test.js +278 -0
- package/dist/commands/mcp/tools.d.ts +8 -1
- package/dist/commands/mcp/tools.js +252 -19
- package/dist/commands/mcp/tools.test.js +151 -3
- package/dist/commands/mcp/wait.d.ts +20 -0
- package/dist/commands/mcp/wait.js +46 -0
- package/dist/commands/mcp/wait.test.d.ts +1 -0
- package/dist/commands/mcp/wait.test.js +91 -0
- package/dist/commands/schema.js +4 -0
- package/dist/commands/session/cancel.d.ts +20 -0
- package/dist/commands/session/cancel.js +39 -0
- package/dist/commands/session/export.d.ts +30 -0
- package/dist/commands/session/export.js +58 -0
- package/dist/commands/session/index.js +2 -0
- package/dist/commands/session/session.test.js +100 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +5 -0
- package/dist/errors.test.js +6 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/session-export.d.ts +30 -0
- package/dist/session-export.js +72 -0
- package/dist/session-export.test.d.ts +1 -0
- package/dist/session-export.test.js +107 -0
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { HttpResponse, http, server, TEST_BASE_URL } from "../../../test-utils.js";
|
|
6
|
+
import { getTestboxConfigPath, getTestboxStatePath } from "../paths.js";
|
|
7
|
+
import { agentLog, agentLogFollowArgs, downTestbox, pendingUp, pruneTestbox, resetTestbox, statusTestbox, upTestbox, waitForTestbox, } from "./box.js";
|
|
8
|
+
import { containerName, DEFAULT_NOVNC_PORT, relayUrl, resolveImage, TESTBOX_IMAGE, TESTBOX_IMAGE_TAG, validateBoxName, } from "./container.js";
|
|
9
|
+
import { TestboxError } from "./errors.js";
|
|
10
|
+
import { fail, fakeDocker, has, ok, starts } from "./fake-docker.js";
|
|
11
|
+
let tempHome;
|
|
12
|
+
let agentDir;
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-testbox-"));
|
|
15
|
+
agentDir = join(tempHome, "agent-src");
|
|
16
|
+
mkdirSync(agentDir);
|
|
17
|
+
writeFileSync(join(agentDir, "remote.py"), "print('agent')\n");
|
|
18
|
+
writeFileSync(join(agentDir, "requirements.txt"), "mss\n");
|
|
19
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
20
|
+
process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
|
|
21
|
+
process.env["AXIMO_API_KEY"] = "test-key";
|
|
22
|
+
server.listen();
|
|
23
|
+
});
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
delete process.env["AXIMO_HOME"];
|
|
26
|
+
delete process.env["AXIMO_BASE_URL"];
|
|
27
|
+
delete process.env["AXIMO_API_KEY"];
|
|
28
|
+
server.close();
|
|
29
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
rmSync(join(tempHome, "testbox"), { recursive: true, force: true });
|
|
33
|
+
delete process.env["AXIMO_TESTBOX_IMAGE"];
|
|
34
|
+
delete process.env["AXIMO_OFFLINE_DIR"];
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => server.resetHandlers());
|
|
37
|
+
function fakeClock() {
|
|
38
|
+
let t = 1_700_000_000_000;
|
|
39
|
+
const sleeps = [];
|
|
40
|
+
return {
|
|
41
|
+
sleeps,
|
|
42
|
+
now: () => t,
|
|
43
|
+
sleep: async (ms) => {
|
|
44
|
+
sleeps.push(ms);
|
|
45
|
+
t += ms;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function apiWithDevices(own, org = own) {
|
|
50
|
+
const registered = [];
|
|
51
|
+
const deleted = [];
|
|
52
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ devices: own })), http.get(`${TEST_BASE_URL}/api/organizations/devices`, () => HttpResponse.json({ devices: org })), http.post(`${TEST_BASE_URL}/api/devices/register`, async ({ request }) => {
|
|
53
|
+
const body = (await request.json());
|
|
54
|
+
registered.push(body);
|
|
55
|
+
const device = { id: `dev-${registered.length}`, name: body.name, status: "online" };
|
|
56
|
+
own.push(device);
|
|
57
|
+
return HttpResponse.json({ deviceId: device.id, deviceToken: `tok-${registered.length}` });
|
|
58
|
+
}), http.delete(`${TEST_BASE_URL}/api/devices/user/:id`, ({ params }) => {
|
|
59
|
+
deleted.push(String(params["id"]));
|
|
60
|
+
return HttpResponse.json({ success: true });
|
|
61
|
+
}));
|
|
62
|
+
return { registered, deleted, own };
|
|
63
|
+
}
|
|
64
|
+
const fetchAgent = async () => ({
|
|
65
|
+
scriptPath: join(agentDir, "remote.py"),
|
|
66
|
+
requirementsPath: join(agentDir, "requirements.txt"),
|
|
67
|
+
});
|
|
68
|
+
function deps(docker = fakeDocker(), clock = fakeClock()) {
|
|
69
|
+
return { docker: docker.run, clock, fetchAgent, hostName: () => "mac.local" };
|
|
70
|
+
}
|
|
71
|
+
function seedToken(name, overrides = {}) {
|
|
72
|
+
mkdirSync(join(tempHome, "testbox"), { recursive: true });
|
|
73
|
+
writeFileSync(getTestboxConfigPath(name), JSON.stringify({
|
|
74
|
+
deviceId: "dev-old",
|
|
75
|
+
deviceToken: "tok-old",
|
|
76
|
+
name: "mac-testbox-abcd",
|
|
77
|
+
registeredAt: "2026-01-01T00:00:00.000Z",
|
|
78
|
+
baseUrl: TEST_BASE_URL,
|
|
79
|
+
...overrides,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
const baseConfig = {
|
|
83
|
+
image: `${TESTBOX_IMAGE}:${TESTBOX_IMAGE_TAG}`,
|
|
84
|
+
ports: "3000",
|
|
85
|
+
blockHosts: [],
|
|
86
|
+
startUrl: undefined,
|
|
87
|
+
network: "bridge",
|
|
88
|
+
novncPort: DEFAULT_NOVNC_PORT,
|
|
89
|
+
screen: "1280x720",
|
|
90
|
+
};
|
|
91
|
+
function runningContainer(docker, cfg = baseConfig, running = true) {
|
|
92
|
+
docker.on(starts("inspect"), ok(`${running}|${cfg.image}|${JSON.stringify({ "aximo.testbox": "default", "aximo.testbox.config": JSON.stringify(cfg) })}\n`));
|
|
93
|
+
}
|
|
94
|
+
describe("validateBoxName", () => {
|
|
95
|
+
it("defaults to the single box and rejects names that would escape a path", () => {
|
|
96
|
+
expect(validateBoxName(undefined)).toBe("default");
|
|
97
|
+
expect(validateBoxName("qa-2")).toBe("qa-2");
|
|
98
|
+
expect(() => validateBoxName("../config")).toThrow(TestboxError);
|
|
99
|
+
expect(() => validateBoxName("")).toThrow(TestboxError);
|
|
100
|
+
expect(containerName("default")).toBe("aximo-testbox");
|
|
101
|
+
expect(containerName("qa")).toBe("aximo-testbox-qa");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe("resolveImage", () => {
|
|
105
|
+
it("pins the published tag unless overridden", () => {
|
|
106
|
+
expect(resolveImage()).toBe(`ghcr.io/autifyhq/aximo-testbox:${TESTBOX_IMAGE_TAG}`);
|
|
107
|
+
process.env["AXIMO_TESTBOX_IMAGE"] = "mirror.local/testbox:1";
|
|
108
|
+
expect(resolveImage()).toBe("mirror.local/testbox:1");
|
|
109
|
+
expect(resolveImage("local:dev")).toBe("local:dev");
|
|
110
|
+
});
|
|
111
|
+
it("matches the tag the image workflow publishes", () => {
|
|
112
|
+
expect(readFileSync(new URL("../../../../testbox/image/TAG", import.meta.url), "utf-8").trim()).toBe(TESTBOX_IMAGE_TAG);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe("upTestbox", () => {
|
|
116
|
+
it("pulls, registers, starts and injects the agent on a fresh machine", async () => {
|
|
117
|
+
const api = apiWithDevices([]);
|
|
118
|
+
const docker = fakeDocker();
|
|
119
|
+
const result = await upTestbox({ ports: "3000,5173", blockHosts: ["prod.example.com"], startUrl: "http://localhost:5173/" }, deps(docker));
|
|
120
|
+
expect(docker.find("pull")[0]?.args).toEqual(["pull", `${TESTBOX_IMAGE}:${TESTBOX_IMAGE_TAG}`]);
|
|
121
|
+
expect(api.registered).toEqual([
|
|
122
|
+
{
|
|
123
|
+
name: expect.stringMatching(/^mac-testbox-[0-9a-f]{4}$/),
|
|
124
|
+
platform: "linux",
|
|
125
|
+
os: "Testbox (Docker)",
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
const run = docker.find("run")[0].args;
|
|
129
|
+
expect(run.slice(0, 5)).toEqual(["run", "-d", "--init", "--name", "aximo-testbox"]);
|
|
130
|
+
expect(run).not.toContain("--network");
|
|
131
|
+
expect(run).toContain("--add-host=host.docker.internal:host-gateway");
|
|
132
|
+
expect(run).toContain("127.0.0.1:6080:6080");
|
|
133
|
+
expect(run).toContain("/run/aximo:rw,size=64m");
|
|
134
|
+
expect(run).toContain("aximo-testbox-profile:/profile");
|
|
135
|
+
expect(run).toContain("AXIMO_TESTBOX_PORTS=3000,5173");
|
|
136
|
+
expect(run).toContain("AXIMO_TESTBOX_BLOCK_HOSTS=prod.example.com");
|
|
137
|
+
expect(run).toContain("AXIMO_TESTBOX_START_URL=http://localhost:5173/");
|
|
138
|
+
expect(run).toContain("AXIMO_TESTBOX_SCREEN=1280x720");
|
|
139
|
+
expect(run.join(" ")).not.toMatch(/tok-|test-key/);
|
|
140
|
+
const writes = docker.calls.filter((c) => c.input !== undefined);
|
|
141
|
+
expect(writes.map((c) => c.args.at(-1))).toEqual([
|
|
142
|
+
"umask 022 && cat > '/run/aximo/requirements.txt'",
|
|
143
|
+
"umask 022 && cat > '/run/aximo/remote.py'",
|
|
144
|
+
"umask 077 && cat > '/run/aximo/agent.env'",
|
|
145
|
+
]);
|
|
146
|
+
expect(writes[2].input).toBe([
|
|
147
|
+
"GATEWAY_URL=wss://aximo.test/devices/tunnel",
|
|
148
|
+
"RELAY_URL=wss://aximo.test/devices/tunnel",
|
|
149
|
+
"DEVICE_TOKEN=tok-1",
|
|
150
|
+
`DEVICE_NAME=${api.registered[0].name}`,
|
|
151
|
+
"",
|
|
152
|
+
].join("\n"));
|
|
153
|
+
expect(docker.find("exec").some((c) => c.args.includes("pkill"))).toBe(false);
|
|
154
|
+
expect(result).toMatchObject({
|
|
155
|
+
name: "default",
|
|
156
|
+
container: "aximo-testbox",
|
|
157
|
+
userDeviceId: "dev-1",
|
|
158
|
+
state: "online",
|
|
159
|
+
liveViewUrl: "http://127.0.0.1:6080/vnc.html?autoconnect=1&resize=scale&password=pw123",
|
|
160
|
+
notes: [],
|
|
161
|
+
});
|
|
162
|
+
const token = JSON.parse(readFileSync(getTestboxConfigPath("default"), "utf-8"));
|
|
163
|
+
expect(token).toMatchObject({
|
|
164
|
+
deviceId: "dev-1",
|
|
165
|
+
deviceToken: "tok-1",
|
|
166
|
+
baseUrl: TEST_BASE_URL,
|
|
167
|
+
});
|
|
168
|
+
expect(statSync(getTestboxConfigPath("default")).mode & 0o777).toBe(0o600);
|
|
169
|
+
expect(existsSync(getTestboxStatePath("default"))).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
it("stops before pulling when Docker is missing and leaves no state behind", async () => {
|
|
172
|
+
apiWithDevices([]);
|
|
173
|
+
const docker = fakeDocker();
|
|
174
|
+
docker.on(starts("info"), () => {
|
|
175
|
+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
176
|
+
});
|
|
177
|
+
await expect(upTestbox({}, deps(docker))).rejects.toThrow(/Docker is not installed/);
|
|
178
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
179
|
+
expect(existsSync(getTestboxStatePath("default"))).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
it("refuses to run while another up is working on the same box", async () => {
|
|
182
|
+
apiWithDevices([]);
|
|
183
|
+
mkdirSync(join(tempHome, "testbox"), { recursive: true });
|
|
184
|
+
writeFileSync(getTestboxStatePath("default"), JSON.stringify({ phase: "pulling", startedAt: new Date().toISOString(), pid: process.ppid }));
|
|
185
|
+
const docker = fakeDocker();
|
|
186
|
+
await expect(upTestbox({}, deps(docker))).rejects.toThrow(/Another 'aximo device testbox up'/);
|
|
187
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
it("loses the race to an up that claims the box during the Docker preflight", async () => {
|
|
190
|
+
apiWithDevices([]);
|
|
191
|
+
const docker = fakeDocker();
|
|
192
|
+
docker.on(starts("info"), () => {
|
|
193
|
+
mkdirSync(join(tempHome, "testbox"), { recursive: true });
|
|
194
|
+
writeFileSync(getTestboxStatePath("default"), JSON.stringify({
|
|
195
|
+
phase: "pulling",
|
|
196
|
+
startedAt: new Date().toISOString(),
|
|
197
|
+
pid: process.ppid,
|
|
198
|
+
}));
|
|
199
|
+
return ok('Docker Desktop|["name=seccomp"]\n');
|
|
200
|
+
});
|
|
201
|
+
await expect(upTestbox({}, deps(docker))).rejects.toThrow(/Another 'aximo device testbox up'/);
|
|
202
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
203
|
+
expect(JSON.parse(readFileSync(getTestboxStatePath("default"), "utf-8"))).toMatchObject({
|
|
204
|
+
phase: "pulling",
|
|
205
|
+
pid: process.ppid,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
it("replaces a state file left by a dead or finished up", async () => {
|
|
209
|
+
apiWithDevices([]);
|
|
210
|
+
mkdirSync(join(tempHome, "testbox"), { recursive: true });
|
|
211
|
+
for (const stale of [
|
|
212
|
+
{ phase: "pulling", startedAt: new Date().toISOString(), pid: 2 ** 22 - 1 },
|
|
213
|
+
{ phase: "error", startedAt: new Date().toISOString(), pid: 1, lastError: "pull failed" },
|
|
214
|
+
]) {
|
|
215
|
+
writeFileSync(getTestboxStatePath("default"), JSON.stringify(stale));
|
|
216
|
+
const docker = fakeDocker();
|
|
217
|
+
await expect(upTestbox({}, deps(docker))).resolves.toMatchObject({ state: "online" });
|
|
218
|
+
expect(docker.find("pull")).toHaveLength(1);
|
|
219
|
+
expect(existsSync(getTestboxStatePath("default"))).toBe(false);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
it("checks the API key and the BYOD flag before pulling", async () => {
|
|
223
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => new HttpResponse(null, { status: 404 })));
|
|
224
|
+
const docker = fakeDocker();
|
|
225
|
+
await expect(upTestbox({}, deps(docker))).rejects.toMatchObject({ status: 403 });
|
|
226
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
227
|
+
expect(JSON.parse(readFileSync(getTestboxStatePath("default"), "utf-8"))).toMatchObject({
|
|
228
|
+
phase: "error",
|
|
229
|
+
lastError: expect.stringContaining("BYOD is not enabled"),
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
it("registers before pulling, so a refused registration costs no download", async () => {
|
|
233
|
+
apiWithDevices([], [{ id: "dev-old", name: "mac-testbox-abcd", status: "offline" }]);
|
|
234
|
+
seedToken("default");
|
|
235
|
+
const docker = fakeDocker();
|
|
236
|
+
await expect(upTestbox({}, deps(docker))).rejects.toThrow(/another API key owner/);
|
|
237
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
238
|
+
});
|
|
239
|
+
it("records a failed pull in the state file and says how to work around it", async () => {
|
|
240
|
+
apiWithDevices([]);
|
|
241
|
+
const docker = fakeDocker();
|
|
242
|
+
docker.on(starts("pull"), fail("denied: requested access to the resource is denied"));
|
|
243
|
+
await expect(upTestbox({}, deps(docker))).rejects.toThrow(/AXIMO_TESTBOX_IMAGE/);
|
|
244
|
+
expect(JSON.parse(readFileSync(getTestboxStatePath("default"), "utf-8"))).toMatchObject({
|
|
245
|
+
phase: "error",
|
|
246
|
+
lastError: expect.stringContaining("denied"),
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
it("loads a staged tarball instead of pulling when AXIMO_OFFLINE_DIR has one", async () => {
|
|
250
|
+
apiWithDevices([]);
|
|
251
|
+
const offline = join(tempHome, "offline");
|
|
252
|
+
mkdirSync(join(offline, "testbox"), { recursive: true });
|
|
253
|
+
const arch = process.arch === "arm64" ? "linux-arm64" : "linux-amd64";
|
|
254
|
+
writeFileSync(join(offline, "testbox", `aximo-testbox-${arch}.tar`), "");
|
|
255
|
+
process.env["AXIMO_OFFLINE_DIR"] = offline;
|
|
256
|
+
const docker = fakeDocker();
|
|
257
|
+
await upTestbox({}, deps(docker));
|
|
258
|
+
expect(docker.find("load")[0]?.args).toEqual([
|
|
259
|
+
"load",
|
|
260
|
+
"-i",
|
|
261
|
+
join(offline, "testbox", `aximo-testbox-${arch}.tar`),
|
|
262
|
+
]);
|
|
263
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
264
|
+
});
|
|
265
|
+
it("starts a stopped box with the same settings and injects without interrupting anything", async () => {
|
|
266
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
267
|
+
seedToken("default");
|
|
268
|
+
const docker = fakeDocker();
|
|
269
|
+
docker.on(starts("image", "inspect"), ok());
|
|
270
|
+
runningContainer(docker, baseConfig, false);
|
|
271
|
+
const result = await upTestbox({}, deps(docker));
|
|
272
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
273
|
+
expect(docker.find("run")).toHaveLength(0);
|
|
274
|
+
expect(docker.find("start")[0]?.args).toEqual(["start", "aximo-testbox"]);
|
|
275
|
+
expect(docker.find("exec").some((c) => c.args.includes("pkill"))).toBe(false);
|
|
276
|
+
expect(result.userDeviceId).toBe("dev-old");
|
|
277
|
+
});
|
|
278
|
+
it("rewrites agent.env and restarts the agent when the running box has a different token", async () => {
|
|
279
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
280
|
+
seedToken("default");
|
|
281
|
+
const docker = fakeDocker();
|
|
282
|
+
docker.on(starts("image", "inspect"), ok());
|
|
283
|
+
runningContainer(docker);
|
|
284
|
+
docker.on(has("exec", "cat", "/run/aximo/agent.env"), ok("GATEWAY_URL=stale\n"));
|
|
285
|
+
await upTestbox({}, deps(docker));
|
|
286
|
+
const writes = docker.calls.filter((c) => c.input !== undefined).map((c) => c.args.at(-1));
|
|
287
|
+
expect(writes).toContain("umask 077 && cat > '/run/aximo/agent.env'");
|
|
288
|
+
expect(docker.find("exec").some((c) => c.args.includes("pkill"))).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
it("leaves a matching agent.env alone so a running session is not interrupted", async () => {
|
|
291
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
292
|
+
seedToken("default");
|
|
293
|
+
const docker = fakeDocker();
|
|
294
|
+
docker.on(starts("image", "inspect"), ok());
|
|
295
|
+
runningContainer(docker);
|
|
296
|
+
const env = [
|
|
297
|
+
"GATEWAY_URL=wss://aximo.test/devices/tunnel",
|
|
298
|
+
"RELAY_URL=wss://aximo.test/devices/tunnel",
|
|
299
|
+
"DEVICE_TOKEN=tok-old",
|
|
300
|
+
"DEVICE_NAME=mac-testbox-abcd",
|
|
301
|
+
"",
|
|
302
|
+
].join("\n");
|
|
303
|
+
docker.on(has("exec", "cat", "/run/aximo/agent.env"), ok(env));
|
|
304
|
+
await upTestbox({}, deps(docker));
|
|
305
|
+
const writes = docker.calls.filter((c) => c.input !== undefined).map((c) => c.args.at(-1));
|
|
306
|
+
expect(writes).not.toContain("umask 077 && cat > '/run/aximo/agent.env'");
|
|
307
|
+
expect(docker.find("exec").some((c) => c.args.includes("pkill"))).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
it("recreates the box when the requested settings differ and drops the Power's old volumes", async () => {
|
|
310
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
311
|
+
seedToken("default");
|
|
312
|
+
const docker = fakeDocker();
|
|
313
|
+
docker.on(starts("image", "inspect"), ok());
|
|
314
|
+
runningContainer(docker, { ...baseConfig, ports: "3000" });
|
|
315
|
+
await upTestbox({ ports: "5173" }, deps(docker));
|
|
316
|
+
expect(docker.find("rm", "-f", "aximo-testbox")).toHaveLength(1);
|
|
317
|
+
expect(docker.find("volume", "rm").map((c) => c.args.at(-1))).toEqual([
|
|
318
|
+
"aximo-testbox-aximo",
|
|
319
|
+
"aximo-testbox-npm",
|
|
320
|
+
]);
|
|
321
|
+
expect(docker.find("run")[0]?.args).toContain("AXIMO_TESTBOX_PORTS=5173");
|
|
322
|
+
});
|
|
323
|
+
it("lets AXIMO_TESTBOX_IMAGE override the running box's image, as a flag would", async () => {
|
|
324
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
325
|
+
seedToken("default");
|
|
326
|
+
process.env["AXIMO_TESTBOX_IMAGE"] = "mirror.local/testbox:2";
|
|
327
|
+
const docker = fakeDocker();
|
|
328
|
+
docker.on(starts("image", "inspect"), ok());
|
|
329
|
+
runningContainer(docker, { ...baseConfig, image: "mirror.local/testbox:1" });
|
|
330
|
+
const result = await upTestbox({}, deps(docker));
|
|
331
|
+
expect(result.config.image).toBe("mirror.local/testbox:2");
|
|
332
|
+
expect(docker.find("run")).toHaveLength(1);
|
|
333
|
+
});
|
|
334
|
+
it("keeps the running box's settings when up is called without flags", async () => {
|
|
335
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
336
|
+
seedToken("default");
|
|
337
|
+
const docker = fakeDocker();
|
|
338
|
+
docker.on(starts("image", "inspect"), ok());
|
|
339
|
+
runningContainer(docker, { ...baseConfig, ports: "8080", blockHosts: ["prod"] });
|
|
340
|
+
const result = await upTestbox({}, deps(docker));
|
|
341
|
+
expect(docker.find("run")).toHaveLength(0);
|
|
342
|
+
expect(result.config.ports).toBe("8080");
|
|
343
|
+
});
|
|
344
|
+
it("replaces the Power's unlabelled container and its API-key volume on first use", async () => {
|
|
345
|
+
apiWithDevices([]);
|
|
346
|
+
const docker = fakeDocker();
|
|
347
|
+
docker.on(starts("inspect"), ok("true|aximo-testbox:local|null\n"));
|
|
348
|
+
await upTestbox({}, deps(docker));
|
|
349
|
+
expect(docker.find("rm", "-f", "aximo-testbox")).toHaveLength(1);
|
|
350
|
+
expect(docker.find("volume", "rm").map((c) => c.args.at(-1))).toEqual([
|
|
351
|
+
"aximo-testbox-aximo",
|
|
352
|
+
"aximo-testbox-npm",
|
|
353
|
+
]);
|
|
354
|
+
expect(docker.find("run")[0]?.args).toContain(`${TESTBOX_IMAGE}:${TESTBOX_IMAGE_TAG}`);
|
|
355
|
+
});
|
|
356
|
+
it("reads a config label whose start URL contains the separator", async () => {
|
|
357
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
358
|
+
seedToken("default");
|
|
359
|
+
const docker = fakeDocker();
|
|
360
|
+
docker.on(starts("image", "inspect"), ok());
|
|
361
|
+
runningContainer(docker, { ...baseConfig, startUrl: "http://localhost:3000/?a=b|c" });
|
|
362
|
+
const result = await upTestbox({}, deps(docker));
|
|
363
|
+
expect(docker.find("run")).toHaveLength(0);
|
|
364
|
+
expect(result.config.startUrl).toBe("http://localhost:3000/?a=b|c");
|
|
365
|
+
});
|
|
366
|
+
it("re-registers when the device row was deleted on the server", async () => {
|
|
367
|
+
const api = apiWithDevices([]);
|
|
368
|
+
seedToken("default");
|
|
369
|
+
const docker = fakeDocker();
|
|
370
|
+
const result = await upTestbox({}, deps(docker));
|
|
371
|
+
expect(api.registered[0]?.name).toBe("mac-testbox-abcd");
|
|
372
|
+
expect(result.userDeviceId).toBe("dev-1");
|
|
373
|
+
});
|
|
374
|
+
it("refuses to register a second row when the device belongs to another key owner", async () => {
|
|
375
|
+
apiWithDevices([], [{ id: "dev-old", name: "mac-testbox-abcd", status: "offline" }]);
|
|
376
|
+
seedToken("default");
|
|
377
|
+
await expect(upTestbox({}, deps())).rejects.toThrow(/another API key owner/);
|
|
378
|
+
});
|
|
379
|
+
it("refuses when the token was issued by a different server", async () => {
|
|
380
|
+
apiWithDevices([]);
|
|
381
|
+
seedToken("default", { baseUrl: "https://other.example" });
|
|
382
|
+
await expect(upTestbox({}, deps())).rejects.toThrow(/registered against https:\/\/other.example/);
|
|
383
|
+
});
|
|
384
|
+
it("uses the host network on native Linux with a display and ports per box", async () => {
|
|
385
|
+
apiWithDevices([]);
|
|
386
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
387
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
388
|
+
try {
|
|
389
|
+
const docker = fakeDocker();
|
|
390
|
+
docker.on(starts("info"), ok('Ubuntu 24.04|["name=seccomp"]\n'));
|
|
391
|
+
docker.on(starts("ps"), ok(`${JSON.stringify(baseConfig)}\n`));
|
|
392
|
+
const result = await upTestbox({ name: "qa" }, deps(docker));
|
|
393
|
+
const run = docker.find("run")[0].args;
|
|
394
|
+
expect(run).toContain("--network");
|
|
395
|
+
expect(run).toContain("host");
|
|
396
|
+
expect(run).not.toContain("-p");
|
|
397
|
+
expect(run).toContain("AXIMO_TESTBOX_NOVNC_PORT=6081");
|
|
398
|
+
expect(run).toContain("AXIMO_TESTBOX_VNC_PORT=5901");
|
|
399
|
+
expect(run).toContain("AXIMO_TESTBOX_DISPLAY=101");
|
|
400
|
+
expect(result.container).toBe("aximo-testbox-qa");
|
|
401
|
+
expect(result.liveViewUrl).toContain(":6081/");
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
Object.defineProperty(process, "platform", platform);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
it("falls back to the bridge on rootless Docker and says why", async () => {
|
|
408
|
+
apiWithDevices([]);
|
|
409
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform");
|
|
410
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
411
|
+
try {
|
|
412
|
+
const docker = fakeDocker();
|
|
413
|
+
docker.on(starts("info"), ok('Ubuntu 24.04|["name=rootless","name=seccomp"]\n'));
|
|
414
|
+
const result = await upTestbox({}, deps(docker));
|
|
415
|
+
expect(docker.find("run")[0].args).toContain("--add-host=host.docker.internal:host-gateway");
|
|
416
|
+
expect(result.notes[0]).toMatch(/Rootless Docker/);
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
Object.defineProperty(process, "platform", platform);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
it("gives the default box the next free live-view port when a named box took 6080", async () => {
|
|
423
|
+
apiWithDevices([]);
|
|
424
|
+
const docker = fakeDocker();
|
|
425
|
+
docker.on(starts("ps"), ok(`${JSON.stringify({ ...baseConfig, novncPort: 6080 })}\n`));
|
|
426
|
+
const result = await upTestbox({}, deps(docker));
|
|
427
|
+
expect(result.config.novncPort).toBe(6081);
|
|
428
|
+
expect(docker.find("run")[0].args).toContain("127.0.0.1:6081:6081");
|
|
429
|
+
});
|
|
430
|
+
it("rejects a live-view port that cannot be mapped to a display", async () => {
|
|
431
|
+
apiWithDevices([]);
|
|
432
|
+
const docker = fakeDocker();
|
|
433
|
+
await expect(upTestbox({ novncPort: Number.NaN }, deps(docker))).rejects.toThrow(/noVNC port/);
|
|
434
|
+
await expect(upTestbox({ novncPort: 80 }, deps(docker))).rejects.toThrow(/noVNC port/);
|
|
435
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
436
|
+
});
|
|
437
|
+
it("caps the mirrored ports and keeps the live view's own ports out of the mirror", async () => {
|
|
438
|
+
apiWithDevices([]);
|
|
439
|
+
const docker = fakeDocker();
|
|
440
|
+
await expect(upTestbox({ ports: "1-65535" }, deps(docker))).rejects.toThrow(/Too many ports/);
|
|
441
|
+
await expect(upTestbox({ ports: "3000,5900" }, deps(docker))).rejects.toThrow(/Port 5900 is used/);
|
|
442
|
+
await expect(upTestbox({ ports: "6080" }, deps(docker))).rejects.toThrow(/Port 6080 is used/);
|
|
443
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
444
|
+
expect(docker.find("run")).toHaveLength(0);
|
|
445
|
+
});
|
|
446
|
+
it("points the agent at host.docker.internal when the server itself is on localhost", () => {
|
|
447
|
+
expect(relayUrl("http://localhost:3000", "bridge")).toBe("ws://host.docker.internal:3000/devices/tunnel");
|
|
448
|
+
expect(relayUrl("http://127.0.0.1:3000", "host")).toBe("ws://127.0.0.1:3000/devices/tunnel");
|
|
449
|
+
expect(relayUrl("http://[::1]:3000", "bridge")).toBe("ws://host.docker.internal:3000/devices/tunnel");
|
|
450
|
+
expect(relayUrl("https://aximo.example.com/aximo/", "bridge")).toBe("wss://aximo.example.com/aximo/devices/tunnel");
|
|
451
|
+
});
|
|
452
|
+
it("sizes the virtual screen from --screen and rejects sizes the box cannot use", async () => {
|
|
453
|
+
apiWithDevices([]);
|
|
454
|
+
const docker = fakeDocker();
|
|
455
|
+
const result = await upTestbox({ screen: "1920x1080" }, deps(docker));
|
|
456
|
+
expect(result.config.screen).toBe("1920x1080");
|
|
457
|
+
expect(docker.find("run")[0]?.args).toContain("AXIMO_TESTBOX_SCREEN=1920x1080");
|
|
458
|
+
for (const screen of ["100x100", "1280*720", "abc", "4096x2160", "1280x720x24"]) {
|
|
459
|
+
const bad = fakeDocker();
|
|
460
|
+
await expect(upTestbox({ screen }, deps(bad))).rejects.toThrow(/Invalid screen/);
|
|
461
|
+
expect(bad.find("pull")).toHaveLength(0);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
it("rejects a bad port spec before touching Docker state", async () => {
|
|
465
|
+
apiWithDevices([]);
|
|
466
|
+
const docker = fakeDocker();
|
|
467
|
+
for (const ports of ["80-70", "3000-3001-9999", "0x10", "3000-", "-3000", "3000.0", "65536"]) {
|
|
468
|
+
await expect(upTestbox({ ports }, deps(docker))).rejects.toThrow(/Invalid port spec/);
|
|
469
|
+
}
|
|
470
|
+
expect(docker.find("pull")).toHaveLength(0);
|
|
471
|
+
});
|
|
472
|
+
it("removes a box it just created when the agent cannot be injected, quoting the box's own log", async () => {
|
|
473
|
+
apiWithDevices([]);
|
|
474
|
+
const docker = fakeDocker();
|
|
475
|
+
docker.on(has("exec", "-i"), fail("container aximo-testbox is not running"));
|
|
476
|
+
docker.on(starts("logs"), ok("[aximo-testbox] FATAL: port 5900 is already in use\n"));
|
|
477
|
+
await expect(upTestbox({}, deps(docker))).rejects.toThrow(/not running[\s\S]*FATAL: port 5900/);
|
|
478
|
+
expect(docker.find("rm", "-f", "aximo-testbox")).toHaveLength(1);
|
|
479
|
+
});
|
|
480
|
+
it("leaves a reused running box alone when injection fails", async () => {
|
|
481
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
482
|
+
seedToken("default");
|
|
483
|
+
const docker = fakeDocker();
|
|
484
|
+
docker.on(starts("image", "inspect"), ok());
|
|
485
|
+
runningContainer(docker);
|
|
486
|
+
docker.on(has("exec", "-i"), fail("exec failed"));
|
|
487
|
+
await expect(upTestbox({}, deps(docker))).rejects.toThrow(/exec failed/);
|
|
488
|
+
expect(docker.find("rm")).toHaveLength(0);
|
|
489
|
+
});
|
|
490
|
+
it("keeps polling through transient API errors and stops on a BYOD 403", async () => {
|
|
491
|
+
const api = apiWithDevices([]);
|
|
492
|
+
let calls = 0;
|
|
493
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => {
|
|
494
|
+
calls += 1;
|
|
495
|
+
if (calls === 2 || calls === 3)
|
|
496
|
+
return new HttpResponse(null, { status: 500 });
|
|
497
|
+
return HttpResponse.json({ devices: api.own });
|
|
498
|
+
}));
|
|
499
|
+
const clock = fakeClock();
|
|
500
|
+
const result = await upTestbox({}, deps(fakeDocker(), clock));
|
|
501
|
+
expect(result.state).toBe("online");
|
|
502
|
+
expect(clock.sleeps).toEqual([3000, 3000]);
|
|
503
|
+
apiWithDevices([]);
|
|
504
|
+
let gone = 0;
|
|
505
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => {
|
|
506
|
+
gone += 1;
|
|
507
|
+
return gone === 1
|
|
508
|
+
? HttpResponse.json({ devices: [] })
|
|
509
|
+
: new HttpResponse(null, { status: 404 });
|
|
510
|
+
}));
|
|
511
|
+
await expect(upTestbox({ name: "qa" }, deps(fakeDocker(), fakeClock()))).rejects.toMatchObject({
|
|
512
|
+
status: 403,
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
it("polls until the device is online and gives up with a note after the deadline", async () => {
|
|
516
|
+
const api = apiWithDevices([]);
|
|
517
|
+
const clock = fakeClock();
|
|
518
|
+
const docker = fakeDocker();
|
|
519
|
+
server.use(http.post(`${TEST_BASE_URL}/api/devices/register`, () => {
|
|
520
|
+
api.own.push({ id: "dev-slow", name: "n", status: "offline" });
|
|
521
|
+
return HttpResponse.json({ deviceId: "dev-slow", deviceToken: "t" });
|
|
522
|
+
}));
|
|
523
|
+
const result = await upTestbox({}, deps(docker, clock));
|
|
524
|
+
expect(result.state).toBe("connecting");
|
|
525
|
+
expect(result.notes[0]).toMatch(/not come online yet/);
|
|
526
|
+
expect(clock.sleeps.length).toBeGreaterThan(30);
|
|
527
|
+
});
|
|
528
|
+
it("returns right after injecting with wait=false and clears the state file so status reads Docker", async () => {
|
|
529
|
+
apiWithDevices([]);
|
|
530
|
+
const clock = fakeClock();
|
|
531
|
+
const result = await upTestbox({ wait: false }, deps(fakeDocker(), clock));
|
|
532
|
+
expect(result.state).toBe("connecting");
|
|
533
|
+
expect(clock.sleeps).toEqual([]);
|
|
534
|
+
expect(existsSync(getTestboxStatePath("default"))).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
describe("statusTestbox and waitForTestbox", () => {
|
|
538
|
+
it("is down with no container and no registration", async () => {
|
|
539
|
+
apiWithDevices([]);
|
|
540
|
+
const status = await statusTestbox(undefined, deps());
|
|
541
|
+
expect(status).toEqual({ name: "default", state: "down", container: "absent" });
|
|
542
|
+
});
|
|
543
|
+
it("reports a Docker daemon failure instead of calling the box down", async () => {
|
|
544
|
+
apiWithDevices([]);
|
|
545
|
+
const docker = fakeDocker();
|
|
546
|
+
docker.on(starts("inspect"), fail("Cannot connect to the Docker daemon at unix:///var/run/docker.sock"));
|
|
547
|
+
await expect(statusTestbox(undefined, deps(docker))).rejects.toThrow(/docker inspect aximo-testbox failed: Cannot connect/);
|
|
548
|
+
});
|
|
549
|
+
it("is down when Docker is not installed at all", async () => {
|
|
550
|
+
apiWithDevices([]);
|
|
551
|
+
const docker = fakeDocker();
|
|
552
|
+
docker.on(starts("inspect"), () => {
|
|
553
|
+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
554
|
+
});
|
|
555
|
+
expect((await statusTestbox(undefined, deps(docker))).state).toBe("down");
|
|
556
|
+
});
|
|
557
|
+
it("reports online with probed ports and the live view", async () => {
|
|
558
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
559
|
+
seedToken("default");
|
|
560
|
+
const docker = fakeDocker();
|
|
561
|
+
runningContainer(docker, { ...baseConfig, ports: "3000,5173" });
|
|
562
|
+
docker.on(has("exec", "curl"), (args) => (args.at(-1).endsWith(":5173/") ? fail() : ok()));
|
|
563
|
+
docker.on(has("exec", "cat", "/run/aximo/agent.status"), ok("connected to gateway\n"));
|
|
564
|
+
const status = await statusTestbox(undefined, deps(docker));
|
|
565
|
+
expect(status).toMatchObject({
|
|
566
|
+
state: "online",
|
|
567
|
+
container: "running",
|
|
568
|
+
agent: "running",
|
|
569
|
+
agentStatus: "connected to gateway",
|
|
570
|
+
ports: [
|
|
571
|
+
{ port: 3000, reachable: true },
|
|
572
|
+
{ port: 5173, reachable: false },
|
|
573
|
+
],
|
|
574
|
+
device: { userDeviceId: "dev-old", name: "mac-testbox-abcd", status: "online" },
|
|
575
|
+
liveViewUrl: "http://127.0.0.1:6080/vnc.html?autoconnect=1&resize=scale&password=pw123",
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
it("reports a stopped container as down and omits the live view until the password exists", async () => {
|
|
579
|
+
apiWithDevices([]);
|
|
580
|
+
const stopped = fakeDocker();
|
|
581
|
+
runningContainer(stopped, baseConfig, false);
|
|
582
|
+
expect(await statusTestbox(undefined, deps(stopped))).toMatchObject({
|
|
583
|
+
state: "down",
|
|
584
|
+
container: "stopped",
|
|
585
|
+
});
|
|
586
|
+
const booting = fakeDocker();
|
|
587
|
+
runningContainer(booting);
|
|
588
|
+
booting.on(has("exec", "cat", "/profile/.vncpass"), fail());
|
|
589
|
+
expect((await statusTestbox(undefined, deps(booting))).liveViewUrl).toBeUndefined();
|
|
590
|
+
});
|
|
591
|
+
it("is connecting while the container runs but the device is offline", async () => {
|
|
592
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "offline" }]);
|
|
593
|
+
seedToken("default");
|
|
594
|
+
const docker = fakeDocker();
|
|
595
|
+
runningContainer(docker);
|
|
596
|
+
docker.on(has("exec", "pgrep"), fail());
|
|
597
|
+
const status = await statusTestbox(undefined, deps(docker));
|
|
598
|
+
expect(status.state).toBe("connecting");
|
|
599
|
+
expect(status.agent).toBe("stopped");
|
|
600
|
+
});
|
|
601
|
+
it("follows a live detached up, ignores a dead one, and surfaces its error", async () => {
|
|
602
|
+
apiWithDevices([]);
|
|
603
|
+
const clock = fakeClock();
|
|
604
|
+
mkdirSync(join(tempHome, "testbox"), { recursive: true });
|
|
605
|
+
const write = (state) => writeFileSync(getTestboxStatePath("default"), JSON.stringify(state));
|
|
606
|
+
write({ phase: "pulling", startedAt: new Date(clock.now()).toISOString(), pid: process.pid });
|
|
607
|
+
expect(pendingUp("default", clock)).toMatchObject({ phase: "pulling" });
|
|
608
|
+
expect((await statusTestbox(undefined, deps(fakeDocker(), clock))).state).toBe("pulling");
|
|
609
|
+
write({ phase: "pulling", startedAt: new Date(clock.now()).toISOString(), pid: 2 ** 22 - 1 });
|
|
610
|
+
expect(pendingUp("default", clock)).toBeNull();
|
|
611
|
+
expect((await statusTestbox(undefined, deps(fakeDocker(), clock))).state).toBe("down");
|
|
612
|
+
write({
|
|
613
|
+
phase: "starting",
|
|
614
|
+
startedAt: new Date(clock.now() - 3_600_000).toISOString(),
|
|
615
|
+
pid: process.pid,
|
|
616
|
+
});
|
|
617
|
+
expect((await statusTestbox(undefined, deps(fakeDocker(), clock))).state).toBe("down");
|
|
618
|
+
write({
|
|
619
|
+
phase: "error",
|
|
620
|
+
startedAt: new Date(clock.now()).toISOString(),
|
|
621
|
+
pid: 1,
|
|
622
|
+
lastError: "pull failed",
|
|
623
|
+
});
|
|
624
|
+
expect(await statusTestbox(undefined, deps(fakeDocker(), clock))).toMatchObject({
|
|
625
|
+
state: "error",
|
|
626
|
+
lastError: "pull failed",
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
it("reports the last failed up even while an older box is still running", async () => {
|
|
630
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "online" }]);
|
|
631
|
+
seedToken("default");
|
|
632
|
+
mkdirSync(join(tempHome, "testbox"), { recursive: true });
|
|
633
|
+
writeFileSync(getTestboxStatePath("default"), JSON.stringify({
|
|
634
|
+
phase: "error",
|
|
635
|
+
startedAt: new Date().toISOString(),
|
|
636
|
+
pid: 1,
|
|
637
|
+
lastError: "pull failed",
|
|
638
|
+
}));
|
|
639
|
+
const docker = fakeDocker();
|
|
640
|
+
runningContainer(docker);
|
|
641
|
+
expect(await statusTestbox(undefined, deps(docker))).toMatchObject({
|
|
642
|
+
state: "error",
|
|
643
|
+
container: "running",
|
|
644
|
+
lastError: "pull failed",
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
it("treats a daemon that is down as an error, not as an absent box", async () => {
|
|
648
|
+
apiWithDevices([]);
|
|
649
|
+
const docker = fakeDocker();
|
|
650
|
+
docker.on(starts("inspect"), fail("Cannot connect to the Docker daemon"));
|
|
651
|
+
await expect(downTestbox(undefined, deps(docker))).rejects.toThrow(/Cannot connect/);
|
|
652
|
+
await expect(pruneTestbox(undefined, {}, deps(docker))).rejects.toThrow(/Cannot connect/);
|
|
653
|
+
});
|
|
654
|
+
it("surfaces an API failure in lastError without hiding the container state", async () => {
|
|
655
|
+
server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => new HttpResponse(null, { status: 500 })));
|
|
656
|
+
seedToken("default");
|
|
657
|
+
const docker = fakeDocker();
|
|
658
|
+
runningContainer(docker);
|
|
659
|
+
const status = await statusTestbox(undefined, deps(docker));
|
|
660
|
+
expect(status.container).toBe("running");
|
|
661
|
+
expect(status.lastError).toBeDefined();
|
|
662
|
+
expect(status.state).toBe("connecting");
|
|
663
|
+
});
|
|
664
|
+
it("waits across polls and returns at the deadline", async () => {
|
|
665
|
+
apiWithDevices([{ id: "dev-old", name: "mac-testbox-abcd", status: "offline" }]);
|
|
666
|
+
seedToken("default");
|
|
667
|
+
const docker = fakeDocker();
|
|
668
|
+
runningContainer(docker);
|
|
669
|
+
const clock = fakeClock();
|
|
670
|
+
const status = await waitForTestbox(undefined, 10, deps(docker, clock));
|
|
671
|
+
expect(status.state).toBe("connecting");
|
|
672
|
+
expect(clock.sleeps).toEqual([3000, 3000, 3000]);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
describe("down, reset, prune", () => {
|
|
676
|
+
it("down stops the agent first and keeps profile and registration", async () => {
|
|
677
|
+
apiWithDevices([]);
|
|
678
|
+
seedToken("default");
|
|
679
|
+
const docker = fakeDocker();
|
|
680
|
+
runningContainer(docker);
|
|
681
|
+
expect(await downTestbox(undefined, deps(docker))).toEqual({ name: "default", removed: true });
|
|
682
|
+
expect(docker.calls.map((c) => c.args.slice(0, 2))).toEqual(expect.arrayContaining([
|
|
683
|
+
["exec", "aximo-testbox"],
|
|
684
|
+
["rm", "-f"],
|
|
685
|
+
]));
|
|
686
|
+
expect(docker.find("volume")).toHaveLength(0);
|
|
687
|
+
expect(existsSync(getTestboxConfigPath("default"))).toBe(true);
|
|
688
|
+
});
|
|
689
|
+
it("down without Docker installed says how to install it", async () => {
|
|
690
|
+
apiWithDevices([]);
|
|
691
|
+
const docker = fakeDocker();
|
|
692
|
+
docker.on(starts("inspect"), () => {
|
|
693
|
+
throw Object.assign(new TestboxError("Docker is not installed. Install Docker Desktop"), {
|
|
694
|
+
code: "ENOENT",
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
await expect(downTestbox(undefined, deps(docker))).rejects.toThrow(/Docker is not installed/);
|
|
698
|
+
});
|
|
699
|
+
it("down is a no-op on an absent box", async () => {
|
|
700
|
+
apiWithDevices([]);
|
|
701
|
+
const docker = fakeDocker();
|
|
702
|
+
expect(await downTestbox("qa", deps(docker))).toEqual({ name: "qa", removed: false });
|
|
703
|
+
expect(docker.find("rm")).toHaveLength(0);
|
|
704
|
+
});
|
|
705
|
+
it("reset also drops the browser profile", async () => {
|
|
706
|
+
apiWithDevices([]);
|
|
707
|
+
const docker = fakeDocker();
|
|
708
|
+
const result = await resetTestbox(undefined, deps(docker));
|
|
709
|
+
expect(result.profileDropped).toBe(true);
|
|
710
|
+
expect(docker.find("volume", "rm")[0]?.args).toEqual([
|
|
711
|
+
"volume",
|
|
712
|
+
"rm",
|
|
713
|
+
"-f",
|
|
714
|
+
"aximo-testbox-profile",
|
|
715
|
+
]);
|
|
716
|
+
});
|
|
717
|
+
it("prune unregisters on the server even when the box is already down, then removes everything", async () => {
|
|
718
|
+
const api = apiWithDevices([]);
|
|
719
|
+
seedToken("default");
|
|
720
|
+
const docker = fakeDocker();
|
|
721
|
+
const result = await pruneTestbox(undefined, {}, deps(docker));
|
|
722
|
+
expect(api.deleted).toEqual(["dev-old"]);
|
|
723
|
+
expect(result).toMatchObject({ deviceId: "dev-old", serverDeleted: true, removed: false });
|
|
724
|
+
expect(docker.find("volume", "rm").map((c) => c.args.at(-1))).toEqual([
|
|
725
|
+
"aximo-testbox-profile",
|
|
726
|
+
"aximo-testbox-aximo",
|
|
727
|
+
"aximo-testbox-npm",
|
|
728
|
+
]);
|
|
729
|
+
expect(docker.find("rmi")[0]?.args).toEqual([
|
|
730
|
+
"rmi",
|
|
731
|
+
"-f",
|
|
732
|
+
`${TESTBOX_IMAGE}:${TESTBOX_IMAGE_TAG}`,
|
|
733
|
+
]);
|
|
734
|
+
expect(existsSync(getTestboxConfigPath("default"))).toBe(false);
|
|
735
|
+
});
|
|
736
|
+
it("prune tolerates a device already gone server-side and can keep the image", async () => {
|
|
737
|
+
apiWithDevices([]);
|
|
738
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/:id`, () => new HttpResponse(null, { status: 404 })));
|
|
739
|
+
seedToken("default");
|
|
740
|
+
const docker = fakeDocker();
|
|
741
|
+
runningContainer(docker, { ...baseConfig, image: "mirror/testbox:9" });
|
|
742
|
+
const result = await pruneTestbox(undefined, { keepImage: true }, deps(docker));
|
|
743
|
+
expect(result.serverDeleted).toBe(false);
|
|
744
|
+
expect(docker.find("rmi")).toHaveLength(0);
|
|
745
|
+
});
|
|
746
|
+
it("prune removes the image the box actually ran", async () => {
|
|
747
|
+
apiWithDevices([]);
|
|
748
|
+
const docker = fakeDocker();
|
|
749
|
+
runningContainer(docker, { ...baseConfig, image: "mirror/testbox:9" });
|
|
750
|
+
await pruneTestbox(undefined, {}, deps(docker));
|
|
751
|
+
expect(docker.find("rmi")[0]?.args).toEqual(["rmi", "-f", "mirror/testbox:9"]);
|
|
752
|
+
});
|
|
753
|
+
it("prune still deletes the server row and the local token when Docker is gone", async () => {
|
|
754
|
+
const api = apiWithDevices([]);
|
|
755
|
+
seedToken("default");
|
|
756
|
+
const docker = fakeDocker();
|
|
757
|
+
docker.on(starts("inspect"), () => {
|
|
758
|
+
throw Object.assign(new Error("spawn docker ENOENT"), { code: "ENOENT" });
|
|
759
|
+
});
|
|
760
|
+
const result = await pruneTestbox(undefined, {}, deps(docker));
|
|
761
|
+
expect(api.deleted).toEqual(["dev-old"]);
|
|
762
|
+
expect(result).toMatchObject({ removed: false, serverDeleted: true });
|
|
763
|
+
expect(docker.find("volume")).toHaveLength(0);
|
|
764
|
+
expect(existsSync(getTestboxConfigPath("default"))).toBe(false);
|
|
765
|
+
});
|
|
766
|
+
it("prune refuses to unregister against a different server than the one it registered with", async () => {
|
|
767
|
+
const api = apiWithDevices([]);
|
|
768
|
+
seedToken("default", { baseUrl: "https://other.example" });
|
|
769
|
+
await expect(pruneTestbox(undefined, {}, deps())).rejects.toThrow(/registered against https:\/\/other.example/);
|
|
770
|
+
expect(api.deleted).toEqual([]);
|
|
771
|
+
expect(existsSync(getTestboxConfigPath("default"))).toBe(true);
|
|
772
|
+
});
|
|
773
|
+
it("prune reports a profile volume it could not remove", async () => {
|
|
774
|
+
apiWithDevices([]);
|
|
775
|
+
const docker = fakeDocker();
|
|
776
|
+
runningContainer(docker);
|
|
777
|
+
docker.on(starts("volume", "rm"), fail("volume is in use"));
|
|
778
|
+
await expect(pruneTestbox(undefined, {}, deps(docker))).rejects.toThrow(/volume is in use/);
|
|
779
|
+
});
|
|
780
|
+
it("prune re-raises server errors other than 404", async () => {
|
|
781
|
+
apiWithDevices([]);
|
|
782
|
+
server.use(http.delete(`${TEST_BASE_URL}/api/devices/user/:id`, () => new HttpResponse(null, { status: 500 })));
|
|
783
|
+
seedToken("default");
|
|
784
|
+
await expect(pruneTestbox(undefined, {}, deps())).rejects.toMatchObject({ status: 500 });
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
describe("agent log", () => {
|
|
788
|
+
it("returns the last lines of the agent log as an envelope payload", async () => {
|
|
789
|
+
const docker = fakeDocker();
|
|
790
|
+
docker.on(has("exec", "tail"), ok("line one\nline two\n"));
|
|
791
|
+
expect(await agentLog("qa", 2, deps(docker))).toEqual({
|
|
792
|
+
name: "qa",
|
|
793
|
+
lines: ["line one", "line two"],
|
|
794
|
+
});
|
|
795
|
+
expect(docker.find("exec")[0]?.args).toEqual([
|
|
796
|
+
"exec",
|
|
797
|
+
"aximo-testbox-qa",
|
|
798
|
+
"tail",
|
|
799
|
+
"-n",
|
|
800
|
+
"2",
|
|
801
|
+
"/var/log/aximo/agent.log",
|
|
802
|
+
]);
|
|
803
|
+
});
|
|
804
|
+
it("follows the agent log of the named box", () => {
|
|
805
|
+
expect(agentLogFollowArgs("qa", 20)).toEqual([
|
|
806
|
+
"exec",
|
|
807
|
+
"aximo-testbox-qa",
|
|
808
|
+
"tail",
|
|
809
|
+
"-n",
|
|
810
|
+
"20",
|
|
811
|
+
"-f",
|
|
812
|
+
"/var/log/aximo/agent.log",
|
|
813
|
+
]);
|
|
814
|
+
});
|
|
815
|
+
});
|