@firstpick/pi-package-remote-webui 0.1.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/LICENSE +21 -0
- package/README.md +77 -0
- package/docs/PLAN.md +367 -0
- package/index.ts +284 -0
- package/lib/remote-core.mjs +350 -0
- package/package.json +55 -0
- package/tests/remote-args.test.mjs +112 -0
- package/tests/remote-webui-control.test.mjs +128 -0
- package/tests/run-all.mjs +22 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_PORT,
|
|
5
|
+
buildRemoteWidgetLines,
|
|
6
|
+
formatStatus,
|
|
7
|
+
generateQrLines,
|
|
8
|
+
parseRemoteArgs,
|
|
9
|
+
requiresOpenConfirmation,
|
|
10
|
+
selectLanUrl,
|
|
11
|
+
tokenizeArgs,
|
|
12
|
+
} from "../lib/remote-core.mjs";
|
|
13
|
+
|
|
14
|
+
test("tokenizeArgs handles quoted values", () => {
|
|
15
|
+
assert.deepEqual(tokenizeArgs('status --name "mobile phone" --port 31500'), ["status", "--name", "mobile phone", "--port", "31500"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parseRemoteArgs defaults to open on the default port", () => {
|
|
19
|
+
assert.deepEqual(parseRemoteArgs(""), {
|
|
20
|
+
action: "open",
|
|
21
|
+
port: DEFAULT_PORT,
|
|
22
|
+
name: undefined,
|
|
23
|
+
yes: false,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("parseRemoteArgs supports action, port, name, and confirmation bypass", () => {
|
|
28
|
+
assert.deepEqual(parseRemoteArgs('status --port 31500 --name "mobile phone" --yes'), {
|
|
29
|
+
action: "status",
|
|
30
|
+
port: 31500,
|
|
31
|
+
name: "mobile phone",
|
|
32
|
+
yes: true,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("parseRemoteArgs supports numeric port shortcut", () => {
|
|
37
|
+
assert.deepEqual(parseRemoteArgs("refresh 31501 -y"), {
|
|
38
|
+
action: "refresh",
|
|
39
|
+
port: 31501,
|
|
40
|
+
name: undefined,
|
|
41
|
+
yes: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("parseRemoteArgs rejects invalid ports and unknown options", () => {
|
|
46
|
+
assert.throws(() => parseRemoteArgs("--port 70000"), /port/i);
|
|
47
|
+
assert.throws(() => parseRemoteArgs("--bogus"), /Unknown option/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("requiresOpenConfirmation is only bypassed for /remote --yes", () => {
|
|
51
|
+
assert.equal(requiresOpenConfirmation(parseRemoteArgs("")), true);
|
|
52
|
+
assert.equal(requiresOpenConfirmation(parseRemoteArgs("--yes")), false);
|
|
53
|
+
assert.equal(requiresOpenConfirmation(parseRemoteArgs("status")), false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("selectLanUrl chooses the first HTTP LAN URL", () => {
|
|
57
|
+
assert.equal(
|
|
58
|
+
selectLanUrl({ networkUrls: ["ftp://ignored", "http://192.168.1.20:31415/", "http://10.0.0.5:31415/"] }),
|
|
59
|
+
"http://192.168.1.20:31415/",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("formatStatus renders offline and online states", () => {
|
|
64
|
+
assert.match(formatStatus({ online: false, url: "http://127.0.0.1:31415/", health: { error: "offline" } }), /Online:\s+no/);
|
|
65
|
+
assert.match(
|
|
66
|
+
formatStatus({
|
|
67
|
+
online: true,
|
|
68
|
+
url: "http://192.168.1.20:31415/",
|
|
69
|
+
health: { data: { webuiVersion: "0.3.8" } },
|
|
70
|
+
network: { open: true, host: "0.0.0.0", port: 31415, networkUrls: ["http://192.168.1.20:31415/"] },
|
|
71
|
+
}),
|
|
72
|
+
/open to LAN/,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("buildRemoteWidgetLines includes QR, URL, auth state, and close instruction", () => {
|
|
77
|
+
const lines = buildRemoteWidgetLines({
|
|
78
|
+
url: "http://192.168.1.20:31415/",
|
|
79
|
+
qrLines: ["QR-A", "QR-B"],
|
|
80
|
+
network: { auth: { enabled: true, pin: "1234" } },
|
|
81
|
+
started: true,
|
|
82
|
+
});
|
|
83
|
+
assert(lines.includes("QR-A"));
|
|
84
|
+
assert(lines.includes("http://192.168.1.20:31415/"));
|
|
85
|
+
assert(lines.some((line) => line.includes("PIN 1234")));
|
|
86
|
+
assert(lines.some((line) => line.includes("/remote close")));
|
|
87
|
+
assert(lines.some((line) => line.includes("Started a Pi Web UI server")));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("generateQrLines accepts an injected QR module", async () => {
|
|
91
|
+
const lines = await generateQrLines("http://example.test/", {
|
|
92
|
+
qrcodeModule: {
|
|
93
|
+
generate(value, options, callback) {
|
|
94
|
+
assert.equal(value, "http://example.test/");
|
|
95
|
+
assert.equal(options.small, true);
|
|
96
|
+
callback("QR\nCODE");
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
assert.deepEqual(lines, ["QR", "CODE"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("generateQrLines reports QR failures without duplicating the URL", async () => {
|
|
104
|
+
const lines = await generateQrLines("http://example.test/", {
|
|
105
|
+
qrcodeModule: {
|
|
106
|
+
generate() {
|
|
107
|
+
throw new Error("boom");
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
assert.deepEqual(lines, ["[QR generation failed: boom]"]);
|
|
112
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import { RemoteWebuiController, closeRemoteWebui, openRemoteWebui } from "../lib/remote-core.mjs";
|
|
6
|
+
|
|
7
|
+
async function withMockWebui(handler, fn) {
|
|
8
|
+
const server = createServer(handler);
|
|
9
|
+
server.listen(0, "127.0.0.1");
|
|
10
|
+
await once(server, "listening");
|
|
11
|
+
const { port } = server.address();
|
|
12
|
+
try {
|
|
13
|
+
return await fn(port);
|
|
14
|
+
} finally {
|
|
15
|
+
server.close();
|
|
16
|
+
await once(server, "close");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sendJson(res, status, body) {
|
|
21
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
22
|
+
res.end(JSON.stringify(body));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("openRemoteWebui starts when offline, opens network, and returns a LAN URL", async () => {
|
|
26
|
+
const calls = [];
|
|
27
|
+
let online = false;
|
|
28
|
+
let networkOpen = false;
|
|
29
|
+
|
|
30
|
+
await withMockWebui((req, res) => {
|
|
31
|
+
calls.push(`${req.method} ${req.url}`);
|
|
32
|
+
if (req.url === "/api/health" && req.method === "GET") {
|
|
33
|
+
if (!online) return sendJson(res, 503, { ok: false, error: "offline" });
|
|
34
|
+
return sendJson(res, 200, { ok: true, webuiVersion: "0.3.8" });
|
|
35
|
+
}
|
|
36
|
+
if (req.url === "/api/network" && req.method === "GET") {
|
|
37
|
+
return sendJson(res, 200, {
|
|
38
|
+
ok: true,
|
|
39
|
+
data: {
|
|
40
|
+
open: networkOpen,
|
|
41
|
+
opening: false,
|
|
42
|
+
closing: false,
|
|
43
|
+
host: networkOpen ? "0.0.0.0" : "127.0.0.1",
|
|
44
|
+
port: 0,
|
|
45
|
+
localUrl: "http://127.0.0.1/",
|
|
46
|
+
networkUrls: networkOpen ? ["http://192.168.1.44:31415/"] : [],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (req.url === "/api/network/open" && req.method === "POST") {
|
|
51
|
+
networkOpen = true;
|
|
52
|
+
return sendJson(res, 202, { ok: true, data: { opening: true } });
|
|
53
|
+
}
|
|
54
|
+
sendJson(res, 404, { ok: false, error: "not found" });
|
|
55
|
+
}, async (port) => {
|
|
56
|
+
const controller = new RemoteWebuiController({ sleepImpl: () => Promise.resolve() });
|
|
57
|
+
const result = await openRemoteWebui({ port }, {
|
|
58
|
+
controller,
|
|
59
|
+
startWebui: async () => {
|
|
60
|
+
calls.push("startWebui");
|
|
61
|
+
online = true;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(result.started, true);
|
|
66
|
+
assert.equal(result.url, "http://192.168.1.44:31415/");
|
|
67
|
+
assert.deepEqual(calls, [
|
|
68
|
+
"GET /api/health",
|
|
69
|
+
"startWebui",
|
|
70
|
+
"GET /api/health",
|
|
71
|
+
"GET /api/network",
|
|
72
|
+
"POST /api/network/open",
|
|
73
|
+
"GET /api/network",
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("openRemoteWebui reuses an already open WebUI", async () => {
|
|
79
|
+
let startCalled = false;
|
|
80
|
+
|
|
81
|
+
await withMockWebui((req, res) => {
|
|
82
|
+
if (req.url === "/api/health" && req.method === "GET") return sendJson(res, 200, { ok: true, webuiVersion: "0.3.8" });
|
|
83
|
+
if (req.url === "/api/network" && req.method === "GET") {
|
|
84
|
+
return sendJson(res, 200, {
|
|
85
|
+
ok: true,
|
|
86
|
+
data: {
|
|
87
|
+
open: true,
|
|
88
|
+
opening: false,
|
|
89
|
+
closing: false,
|
|
90
|
+
host: "0.0.0.0",
|
|
91
|
+
port: 0,
|
|
92
|
+
localUrl: "http://127.0.0.1/",
|
|
93
|
+
networkUrls: ["http://10.0.0.8:31415/"],
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
sendJson(res, 404, { ok: false });
|
|
98
|
+
}, async (port) => {
|
|
99
|
+
const controller = new RemoteWebuiController({ sleepImpl: () => Promise.resolve() });
|
|
100
|
+
const result = await openRemoteWebui({ port }, {
|
|
101
|
+
controller,
|
|
102
|
+
startWebui: async () => {
|
|
103
|
+
startCalled = true;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.equal(startCalled, false);
|
|
108
|
+
assert.equal(result.started, false);
|
|
109
|
+
assert.equal(result.url, "http://10.0.0.8:31415/");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("closeRemoteWebui calls the close endpoint when online", async () => {
|
|
114
|
+
const calls = [];
|
|
115
|
+
|
|
116
|
+
await withMockWebui((req, res) => {
|
|
117
|
+
calls.push(`${req.method} ${req.url}`);
|
|
118
|
+
if (req.url === "/api/health" && req.method === "GET") return sendJson(res, 200, { ok: true, webuiVersion: "0.3.8" });
|
|
119
|
+
if (req.url === "/api/network/close" && req.method === "POST") return sendJson(res, 202, { ok: true, data: { open: false } });
|
|
120
|
+
sendJson(res, 404, { ok: false });
|
|
121
|
+
}, async (port) => {
|
|
122
|
+
const controller = new RemoteWebuiController({ sleepImpl: () => Promise.resolve() });
|
|
123
|
+
const result = await closeRemoteWebui({ port }, { controller });
|
|
124
|
+
|
|
125
|
+
assert.equal(result.online, true);
|
|
126
|
+
assert.deepEqual(calls, ["GET /api/health", "POST /api/network/close"]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
8
|
+
const tests = [
|
|
9
|
+
path.join("tests", "remote-args.test.mjs"),
|
|
10
|
+
path.join("tests", "remote-webui-control.test.mjs"),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const result = spawnSync(process.execPath, ["--test", ...tests], {
|
|
14
|
+
cwd: packageRoot,
|
|
15
|
+
stdio: "inherit",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (result.error) {
|
|
19
|
+
console.error(result.error);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
process.exit(result.status ?? 1);
|