@phystack/device-simulator 6.3.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/CHANGELOG.md +14 -0
- package/build-binary.sh +37 -0
- package/dist/index.js +37 -0
- package/package.json +38 -0
- package/src/__tests__/e2e/binary.e2e.test.ts +394 -0
- package/src/__tests__/preload.ts +44 -0
- package/src/command.ts +66 -0
- package/src/commands/__tests__/run-helpers.test.ts +181 -0
- package/src/commands/list.ts +25 -0
- package/src/commands/remove.ts +16 -0
- package/src/commands/run.ts +518 -0
- package/src/commands/start.ts +309 -0
- package/src/index.ts +45 -0
- package/src/services/__tests__/dev-token.test.ts +156 -0
- package/src/services/dev-token.ts +52 -0
- package/src/services/env.ts +10 -0
- package/src/simulator/__tests__/message-router.test.ts +782 -0
- package/src/simulator/__tests__/twin-cache.test.ts +129 -0
- package/src/simulator/index.ts +200 -0
- package/src/simulator/local-server.ts +184 -0
- package/src/simulator/logger.ts +44 -0
- package/src/simulator/message-router.ts +525 -0
- package/src/simulator/twin-cache.ts +61 -0
- package/src/simulator/types.ts +53 -0
- package/src/utils/__tests__/simulator-config.test.ts +230 -0
- package/src/utils/config-paths.ts +38 -0
- package/src/utils/index.ts +41 -0
- package/src/utils/simulator-config.ts +185 -0
- package/src/utils/tenant-storage.ts +106 -0
- package/tsconfig.json +12 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @phystack/device-simulator
|
|
2
|
+
|
|
3
|
+
## 6.3.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#556](https://github.com/phystack/ps-platform/pull/556) [`4433273`](https://github.com/phystack/ps-platform/commit/4433273be94e88b8843aec6261cdb1e79936dc26) Thanks [@hassellof](https://github.com/hassellof)! - Ship `@phystack/device-simulator` as a published npm package and build a per-target standalone `phy-simulator` Bun binary for each developer platform.
|
|
8
|
+
|
|
9
|
+
The package was extracted from `tools/cli` but never wired into the publish pipeline, so a published `@phystack/cli` (which imports it via `workspace:*`) could not resolve it. It is now registered in the Changesets fixed group and the root `ci:build-packages`/`ci:publish-packages` scripts so the library is versioned and published like the other shared packages.
|
|
10
|
+
|
|
11
|
+
In addition, `scripts/build-device-binaries.sh` and `scripts/publish-device-binaries.js` now cross-compile `phy-simulator` and publish per-platform binary subpackages (`@phystack/phy-simulator-<platform>`) for the same darwin/linux/windows/musl matrix as the `phy` CLI, mirroring the existing device-binary publish flow in `deploy-device-apps.yml`.
|
|
12
|
+
|
|
13
|
+
- Updated dependencies []:
|
|
14
|
+
- @phystack/hub-device@6.3.0
|
package/build-binary.sh
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
4
|
+
|
|
5
|
+
# Args: [target] [outfile]
|
|
6
|
+
# target — full Bun cross-compile target (e.g. bun-linux-x64,
|
|
7
|
+
# bun-linux-arm64, bun-darwin-x64, bun-darwin-arm64,
|
|
8
|
+
# bun-windows-x64). Defaults to the host target (no --target).
|
|
9
|
+
# outfile — output binary path. Defaults to "phy-simulator"
|
|
10
|
+
# ("phy-simulator.exe" when target is a Windows one).
|
|
11
|
+
TARGET="${1:-}"
|
|
12
|
+
|
|
13
|
+
DEFAULT_OUTFILE="phy-simulator"
|
|
14
|
+
if [[ "$TARGET" == bun-windows-* ]]; then
|
|
15
|
+
DEFAULT_OUTFILE="phy-simulator.exe"
|
|
16
|
+
fi
|
|
17
|
+
OUTFILE="${2:-$DEFAULT_OUTFILE}"
|
|
18
|
+
|
|
19
|
+
TARGET_ARGS=()
|
|
20
|
+
if [[ -n "$TARGET" ]]; then
|
|
21
|
+
TARGET_ARGS+=("--target=${TARGET}")
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
bun build --compile --minify \
|
|
25
|
+
--no-compile-autoload-dotenv \
|
|
26
|
+
--define '__PKG_VERSION__="'"$VERSION"'"' \
|
|
27
|
+
${TARGET_ARGS[@]+"${TARGET_ARGS[@]}"} \
|
|
28
|
+
./src/index.ts --outfile "$OUTFILE"
|
|
29
|
+
|
|
30
|
+
# macOS AMFI SIGKILLs arm64 Mach-Os that lack a valid signature. Bun's
|
|
31
|
+
# cross-compile on Linux already emits ad-hoc-signed darwin binaries, but
|
|
32
|
+
# a local macOS host build may not — force a fresh ad-hoc signature so the
|
|
33
|
+
# resulting binary is runnable on the same machine that built it.
|
|
34
|
+
if [[ "$(uname -s)" == "Darwin" && "$(file "$OUTFILE")" == *Mach-O* ]]; then
|
|
35
|
+
codesign --remove-signature "$OUTFILE" 2>/dev/null || true
|
|
36
|
+
codesign --force --sign - "$OUTFILE"
|
|
37
|
+
fi
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const commander_1 = require("commander");
|
|
7
|
+
const utils_1 = require("./utils");
|
|
8
|
+
const start_1 = __importDefault(require("./commands/start"));
|
|
9
|
+
const run_1 = __importDefault(require("./commands/run"));
|
|
10
|
+
const list_1 = __importDefault(require("./commands/list"));
|
|
11
|
+
const remove_1 = __importDefault(require("./commands/remove"));
|
|
12
|
+
const program = new commander_1.Command();
|
|
13
|
+
program.version((0, utils_1.getPackageVersion)());
|
|
14
|
+
program.description("Local device simulator for app development");
|
|
15
|
+
program
|
|
16
|
+
.command("start")
|
|
17
|
+
.description("Start the local simulator server")
|
|
18
|
+
.option("-p, --port <port>", "Port to listen on", "55000")
|
|
19
|
+
.option("--connect <name>", "Connect to phyhub as a provisioned device")
|
|
20
|
+
.action((0, utils_1.handleError)(start_1.default));
|
|
21
|
+
program
|
|
22
|
+
.command("run <path>")
|
|
23
|
+
.description("Run an app connected to the simulator (reconciles twin, spawns dev server)")
|
|
24
|
+
.option("--type <type>", "Override app type detection (screen or edge)")
|
|
25
|
+
.option("--dev-command <command>", "Override dev command (default: npm run dev)")
|
|
26
|
+
.option("--settings-dir <path>", "Settings directory path (default: src/settings)")
|
|
27
|
+
.action((0, utils_1.handleError)(run_1.default));
|
|
28
|
+
program
|
|
29
|
+
.command("list")
|
|
30
|
+
.description("List all provisioned simulator devices")
|
|
31
|
+
.action(list_1.default);
|
|
32
|
+
program
|
|
33
|
+
.command("remove <name>")
|
|
34
|
+
.description("Remove a saved simulator device")
|
|
35
|
+
.action(remove_1.default);
|
|
36
|
+
program.parse(process.argv);
|
|
37
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@phystack/device-simulator",
|
|
3
|
+
"version": "6.3.0",
|
|
4
|
+
"description": "Standalone PhyStack device simulator for local app development.",
|
|
5
|
+
"main": "dist/command.js",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "restricted",
|
|
9
|
+
"registry": "https://registry.npmjs.org"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"phy-simulator": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "rimraf dist && tsc",
|
|
16
|
+
"build:binary": "bash build-binary.sh",
|
|
17
|
+
"build:binary:target": "bash build-binary.sh",
|
|
18
|
+
"lint": "tsc --noEmit",
|
|
19
|
+
"test": "bun test src",
|
|
20
|
+
"test:ci": "bun test src"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@phystack/hub-device": "6.3.0",
|
|
24
|
+
"chalk": "^3.0.0",
|
|
25
|
+
"commander": "^5.0.0",
|
|
26
|
+
"fs-extra": "^8.1.0",
|
|
27
|
+
"socket.io": "^4.7.5",
|
|
28
|
+
"socket.io-client": "^4.7.5",
|
|
29
|
+
"uuid": "^11.0.5"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@phystack/tsconfig": "5.0.0",
|
|
33
|
+
"@types/fs-extra": "^8.0.1",
|
|
34
|
+
"@types/node": "^20.14.9",
|
|
35
|
+
"rimraf": "^6.0.1",
|
|
36
|
+
"typescript": "^5.5.2"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawn, spawnSync, type ChildProcess } from "child_process";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import net from "net";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { io as socketIOClient, type Socket } from "socket.io-client";
|
|
9
|
+
|
|
10
|
+
// --- Locate package root + the compiled binary ----------------------------
|
|
11
|
+
const PACKAGE_ROOT = path.resolve(
|
|
12
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
13
|
+
"../../..",
|
|
14
|
+
);
|
|
15
|
+
const BINARY = path.join(PACKAGE_ROOT, "device-simulator-test");
|
|
16
|
+
|
|
17
|
+
// Build the host binary once, here, if it isn't already present. This is the
|
|
18
|
+
// crux of the e2e suite: every assertion below runs against the COMPILED Bun
|
|
19
|
+
// binary (a child process), never against the TypeScript source.
|
|
20
|
+
function ensureBinaryBuilt(): void {
|
|
21
|
+
if (fs.existsSync(BINARY)) return;
|
|
22
|
+
const result = spawnSync(
|
|
23
|
+
"bash",
|
|
24
|
+
["build-binary.sh", "", "./device-simulator-test"],
|
|
25
|
+
{
|
|
26
|
+
cwd: PACKAGE_ROOT,
|
|
27
|
+
stdio: "pipe",
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Failed to build the device-simulator test binary: ${result.stderr || result.stdout}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
ensureBinaryBuilt();
|
|
38
|
+
|
|
39
|
+
// Each e2e gets its own throwaway HOME so the child binary's config-paths
|
|
40
|
+
// (CONFIG_DIR = $HOME/.config/phystack-cli) resolve under a temp dir — never the
|
|
41
|
+
// real home. The binary reads HOME from its spawn env (process env, not the OS
|
|
42
|
+
// user db, for the child). Tracked for afterAll cleanup.
|
|
43
|
+
const tempHomes: string[] = [];
|
|
44
|
+
function makeTempHome(): string {
|
|
45
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sim-e2e-home-"));
|
|
46
|
+
tempHomes.push(dir);
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Track spawned simulator processes so we always kill them, even on assertion
|
|
51
|
+
// failure (cleanup is the sanctioned use of afterAll/finally per the test rules).
|
|
52
|
+
const runningProcesses: ChildProcess[] = [];
|
|
53
|
+
function trackProcess(child: ChildProcess): ChildProcess {
|
|
54
|
+
runningProcesses.push(child);
|
|
55
|
+
return child;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
for (const child of runningProcesses) {
|
|
60
|
+
if (!child.killed) {
|
|
61
|
+
try {
|
|
62
|
+
child.kill("SIGKILL");
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(
|
|
65
|
+
`Failed to kill simulator process ${child.pid}: ${
|
|
66
|
+
error instanceof Error ? error.message : String(error)
|
|
67
|
+
}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const dir of tempHomes) {
|
|
73
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- Helpers ---------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function runBinary(
|
|
80
|
+
args: string[],
|
|
81
|
+
home: string,
|
|
82
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
83
|
+
const result = spawnSync(BINARY, args, {
|
|
84
|
+
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
timeout: 15000,
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
status: result.status,
|
|
90
|
+
stdout: result.stdout ?? "",
|
|
91
|
+
stderr: result.stderr ?? "",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getFreePort(): Promise<number> {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const server = net.createServer();
|
|
98
|
+
server.unref();
|
|
99
|
+
server.on("error", reject);
|
|
100
|
+
server.listen(0, "127.0.0.1", () => {
|
|
101
|
+
const address = server.address();
|
|
102
|
+
if (address && typeof address === "object") {
|
|
103
|
+
const { port } = address;
|
|
104
|
+
server.close(() => resolve(port));
|
|
105
|
+
} else {
|
|
106
|
+
server.close(() =>
|
|
107
|
+
reject(new Error("Could not determine a free port")),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function waitForListening(port: number, timeoutMs = 10000): Promise<void> {
|
|
115
|
+
const deadline = Date.now() + timeoutMs;
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const attempt = () => {
|
|
118
|
+
const socket = net.connect(port, "127.0.0.1");
|
|
119
|
+
socket.on("connect", () => {
|
|
120
|
+
socket.end();
|
|
121
|
+
resolve();
|
|
122
|
+
});
|
|
123
|
+
socket.on("error", () => {
|
|
124
|
+
socket.destroy();
|
|
125
|
+
if (Date.now() > deadline) {
|
|
126
|
+
reject(
|
|
127
|
+
new Error(
|
|
128
|
+
`Simulator did not start listening on port ${port} in time`,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
setTimeout(attempt, 100);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
attempt();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function startSimulator(port: number, home: string): ChildProcess {
|
|
141
|
+
const child = spawn(BINARY, ["start", "--port", String(port)], {
|
|
142
|
+
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
143
|
+
stdio: "pipe",
|
|
144
|
+
});
|
|
145
|
+
return trackProcess(child);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function connectClient(port: number): Promise<Socket> {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const socket = socketIOClient(`http://localhost:${port}`, {
|
|
151
|
+
reconnectionAttempts: 1,
|
|
152
|
+
timeout: 5000,
|
|
153
|
+
});
|
|
154
|
+
socket.on("connect", () => resolve(socket));
|
|
155
|
+
socket.on("connect_error", (error: Error) =>
|
|
156
|
+
reject(new Error(`Cannot connect to simulator: ${error.message}`)),
|
|
157
|
+
);
|
|
158
|
+
setTimeout(
|
|
159
|
+
() => reject(new Error("Timed out connecting to simulator")),
|
|
160
|
+
8000,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function emitSimulator(
|
|
166
|
+
socket: Socket,
|
|
167
|
+
payload: Record<string, unknown>,
|
|
168
|
+
): Promise<any> {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const timer = setTimeout(
|
|
171
|
+
() => reject(new Error("Timed out waiting for simulator ack")),
|
|
172
|
+
8000,
|
|
173
|
+
);
|
|
174
|
+
socket.emit("simulator", payload, (response: any) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
resolve(response);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- 1. CLI surface parity -------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe("e2e: CLI surface (compiled binary)", () => {
|
|
184
|
+
test("--help lists every command", () => {
|
|
185
|
+
const home = makeTempHome();
|
|
186
|
+
const { stdout, status } = runBinary(["--help"], home);
|
|
187
|
+
expect(status).toBe(0);
|
|
188
|
+
expect(stdout).toContain("start");
|
|
189
|
+
expect(stdout).toContain("run");
|
|
190
|
+
expect(stdout).toContain("list");
|
|
191
|
+
expect(stdout).toContain("remove");
|
|
192
|
+
expect(stdout).toContain("Local device simulator for app development");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("start --help shows the exact option strings and defaults", () => {
|
|
196
|
+
const home = makeTempHome();
|
|
197
|
+
const { stdout } = runBinary(["start", "--help"], home);
|
|
198
|
+
expect(stdout).toContain("-p, --port <port>");
|
|
199
|
+
expect(stdout).toContain('(default: "55000")');
|
|
200
|
+
expect(stdout).toContain("--connect <name>");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("run --help shows the type / dev-command / settings-dir options", () => {
|
|
204
|
+
const home = makeTempHome();
|
|
205
|
+
const { stdout } = runBinary(["run", "--help"], home);
|
|
206
|
+
expect(stdout).toContain("--type <type>");
|
|
207
|
+
expect(stdout).toContain("--dev-command <command>");
|
|
208
|
+
expect(stdout).toContain("--settings-dir <path>");
|
|
209
|
+
expect(stdout).toMatch(/run \[options\] <path>/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("--version prints a semver", () => {
|
|
213
|
+
const home = makeTempHome();
|
|
214
|
+
const { stdout, status } = runBinary(["--version"], home);
|
|
215
|
+
expect(status).toBe(0);
|
|
216
|
+
expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// --- 2. list / remove against a temp HOME ---------------------------------
|
|
221
|
+
|
|
222
|
+
describe("e2e: list / remove persistence contract (compiled binary)", () => {
|
|
223
|
+
test("seeded simulator shows in list, then remove deletes it", () => {
|
|
224
|
+
const home = makeTempHome();
|
|
225
|
+
const simulatorsDir = path.join(
|
|
226
|
+
home,
|
|
227
|
+
".config",
|
|
228
|
+
"phystack-cli",
|
|
229
|
+
"simulators",
|
|
230
|
+
);
|
|
231
|
+
fs.mkdirSync(simulatorsDir, { recursive: true });
|
|
232
|
+
|
|
233
|
+
const seeded = {
|
|
234
|
+
name: "foo",
|
|
235
|
+
deviceId: "device-foo-123",
|
|
236
|
+
accessKey: "key-foo",
|
|
237
|
+
serialNumber: "SIM-FOO",
|
|
238
|
+
environment: "dev",
|
|
239
|
+
provisionedAt: "2026-06-16T12:00:00.000Z",
|
|
240
|
+
tenantId: "tenant-foo",
|
|
241
|
+
};
|
|
242
|
+
fs.writeFileSync(
|
|
243
|
+
path.join(simulatorsDir, "foo.json"),
|
|
244
|
+
JSON.stringify(seeded, null, 2),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const listed = runBinary(["list"], home);
|
|
248
|
+
expect(listed.status).toBe(0);
|
|
249
|
+
expect(listed.stdout).toContain("foo");
|
|
250
|
+
expect(listed.stdout).toContain("device-foo-123");
|
|
251
|
+
|
|
252
|
+
const removed = runBinary(["remove", "foo"], home);
|
|
253
|
+
expect(removed.status).toBe(0);
|
|
254
|
+
expect(removed.stdout).toContain('Removed simulator "foo".');
|
|
255
|
+
expect(fs.existsSync(path.join(simulatorsDir, "foo.json"))).toBe(false);
|
|
256
|
+
|
|
257
|
+
const emptyList = runBinary(["list"], home);
|
|
258
|
+
expect(emptyList.stdout).toContain("No provisioned simulators");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("remove of a non-existent simulator exits non-zero", () => {
|
|
262
|
+
const home = makeTempHome();
|
|
263
|
+
const { status, stderr } = runBinary(["remove", "ghost"], home);
|
|
264
|
+
expect(status).not.toBe(0);
|
|
265
|
+
expect(stderr).toContain('Simulator "ghost" not found.');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("list with no config prints the empty-state hint", () => {
|
|
269
|
+
const home = makeTempHome();
|
|
270
|
+
const { stdout, status } = runBinary(["list"], home);
|
|
271
|
+
expect(status).toBe(0);
|
|
272
|
+
expect(stdout).toContain("No provisioned simulators");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// --- 3. start server + socket.io handshake (the heart) --------------------
|
|
277
|
+
|
|
278
|
+
describe("e2e: start server + socket.io protocol (compiled binary)", () => {
|
|
279
|
+
test("full twin handshake: status, create, get, report patch echo", async () => {
|
|
280
|
+
const home = makeTempHome();
|
|
281
|
+
const port = await getFreePort();
|
|
282
|
+
const child = startSimulator(port, home);
|
|
283
|
+
let client: Socket | undefined;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
await waitForListening(port);
|
|
287
|
+
client = await connectClient(port);
|
|
288
|
+
|
|
289
|
+
// ping → pong
|
|
290
|
+
expect(await emitSimulator(client, { method: "ping" })).toEqual({
|
|
291
|
+
status: "success",
|
|
292
|
+
message: "pong",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// getDeviceStatus reports the synthetic local Device twin. tenantId is the
|
|
296
|
+
// deterministic mockObjectId('local') the simulator derives at startup.
|
|
297
|
+
const status = await emitSimulator(client, { method: "getDeviceStatus" });
|
|
298
|
+
expect(status.status).toBe("success");
|
|
299
|
+
expect(status.socketConnected).toBe(true);
|
|
300
|
+
expect(status.tenantId).toBe("25bf8e1a2393f1108d37029b");
|
|
301
|
+
expect(status.displayName).toBe("Local Simulator");
|
|
302
|
+
expect(status.twins.Device).toHaveLength(1);
|
|
303
|
+
|
|
304
|
+
// createInstanceTwin registers a Screen twin and echoes it back.
|
|
305
|
+
const created = await emitSimulator(client, {
|
|
306
|
+
method: "createInstanceTwin",
|
|
307
|
+
data: { type: "Screen", desiredProperties: { appName: "e2e-app" } },
|
|
308
|
+
});
|
|
309
|
+
expect(created.status).toBe("success");
|
|
310
|
+
const twinId = created.twin.id as string;
|
|
311
|
+
expect(twinId).toBeTruthy();
|
|
312
|
+
expect(created.twin.type).toBe("Screen");
|
|
313
|
+
expect(created.twin.properties.desired).toEqual({ appName: "e2e-app" });
|
|
314
|
+
|
|
315
|
+
// getTwinById round-trips the freshly created twin.
|
|
316
|
+
const fetched = await emitSimulator(client, {
|
|
317
|
+
method: "getTwinById",
|
|
318
|
+
data: { twinId },
|
|
319
|
+
});
|
|
320
|
+
expect(fetched.status).toBe("success");
|
|
321
|
+
expect(fetched.twin.id).toBe(twinId);
|
|
322
|
+
|
|
323
|
+
// Subscribe to the twin so the reported-property patch is echoed back as a
|
|
324
|
+
// twinMessage/twinUpdated to this socket.
|
|
325
|
+
const twinUpdate = new Promise<any>((resolve, reject) => {
|
|
326
|
+
const timer = setTimeout(
|
|
327
|
+
() => reject(new Error("No twinUpdated received")),
|
|
328
|
+
8000,
|
|
329
|
+
);
|
|
330
|
+
client!.on("twinMessage", (message: any) => {
|
|
331
|
+
if (message?.method === "twinUpdated" && message?.twinId === twinId) {
|
|
332
|
+
clearTimeout(timer);
|
|
333
|
+
resolve(message);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
await emitSimulator(client, { method: "twinSubscribe", twinId });
|
|
338
|
+
|
|
339
|
+
// reportScreenTwinProperties merges reported props and emits twinUpdated.
|
|
340
|
+
const reported = await emitSimulator(client, {
|
|
341
|
+
method: "reportScreenTwinProperties",
|
|
342
|
+
twinId,
|
|
343
|
+
data: { online: true },
|
|
344
|
+
});
|
|
345
|
+
expect(reported.status).toBe("success");
|
|
346
|
+
expect(reported.twin.properties.reported).toEqual({ online: true });
|
|
347
|
+
|
|
348
|
+
// The simulator pushed the update back over the socket (twinMessage).
|
|
349
|
+
const echoed = await twinUpdate;
|
|
350
|
+
expect(echoed.data.id).toBe(twinId);
|
|
351
|
+
expect(echoed.data.properties.reported).toEqual({ online: true });
|
|
352
|
+
|
|
353
|
+
// Unknown method → structured error.
|
|
354
|
+
expect(await emitSimulator(client, { method: "bogusMethod" })).toEqual({
|
|
355
|
+
status: "error",
|
|
356
|
+
message: "Unknown method: bogusMethod",
|
|
357
|
+
});
|
|
358
|
+
} finally {
|
|
359
|
+
if (client) client.disconnect();
|
|
360
|
+
child.kill("SIGTERM");
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("start persists the device identity under the temp HOME (not the real home)", async () => {
|
|
365
|
+
const home = makeTempHome();
|
|
366
|
+
const port = await getFreePort();
|
|
367
|
+
const child = startSimulator(port, home);
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
await waitForListening(port);
|
|
371
|
+
const deviceConfigPath = path.join(
|
|
372
|
+
home,
|
|
373
|
+
".config",
|
|
374
|
+
"phystack-cli",
|
|
375
|
+
"simulator",
|
|
376
|
+
"tenants",
|
|
377
|
+
"local",
|
|
378
|
+
"device.json",
|
|
379
|
+
);
|
|
380
|
+
// Give the start sequence a beat to flush the device config to disk.
|
|
381
|
+
const deadline = Date.now() + 5000;
|
|
382
|
+
while (!fs.existsSync(deviceConfigPath) && Date.now() < deadline) {
|
|
383
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
384
|
+
}
|
|
385
|
+
expect(fs.existsSync(deviceConfigPath)).toBe(true);
|
|
386
|
+
const config = JSON.parse(fs.readFileSync(deviceConfigPath, "utf8"));
|
|
387
|
+
expect(config.tenantId).toBe("25bf8e1a2393f1108d37029b");
|
|
388
|
+
expect(typeof config.deviceId).toBe("string");
|
|
389
|
+
expect(typeof config.deviceTwinId).toBe("string");
|
|
390
|
+
} finally {
|
|
391
|
+
child.kill("SIGTERM");
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
// Bun runs every test file in a SINGLE process with a SHARED module registry.
|
|
6
|
+
// config-paths.ts computes CONFIG_DIR from os.homedir() at import time, and
|
|
7
|
+
// os.homedir() ignores process.env.HOME on macOS/Linux (it reads the OS user
|
|
8
|
+
// database). If any test file imports config-paths (directly or transitively,
|
|
9
|
+
// e.g. via commands/run -> utils/simulator-config) before an override is in
|
|
10
|
+
// place, CONFIG_DIR locks onto the REAL ~/.config/phystack-cli and every later
|
|
11
|
+
// override is too late — tests then read/write the user's real config.
|
|
12
|
+
//
|
|
13
|
+
// This preload runs BEFORE any test module is evaluated (wired via
|
|
14
|
+
// bunfig.toml [test].preload), so it is the single, authoritative place to
|
|
15
|
+
// redirect os.homedir() to a throwaway temp dir for the whole suite. Persistence
|
|
16
|
+
// tests share this one config root; they use unique device/tenant/simulator
|
|
17
|
+
// names so they don't collide.
|
|
18
|
+
//
|
|
19
|
+
// Exported so individual test files can reference the same root and assert
|
|
20
|
+
// isolation.
|
|
21
|
+
export const SUITE_TEST_HOME: string = fs.mkdtempSync(
|
|
22
|
+
path.join(os.tmpdir(), "ps-sim-suite-home-"),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const realHomedir = os.homedir;
|
|
26
|
+
(os as { homedir: () => string }).homedir = () => SUITE_TEST_HOME;
|
|
27
|
+
process.env.HOME = SUITE_TEST_HOME;
|
|
28
|
+
process.env.USERPROFILE = SUITE_TEST_HOME;
|
|
29
|
+
|
|
30
|
+
// Best-effort cleanup of the suite temp dir when the process exits. Restoring
|
|
31
|
+
// the original homedir keeps any post-test tooling honest.
|
|
32
|
+
process.on("exit", () => {
|
|
33
|
+
(os as { homedir: () => string }).homedir = realHomedir;
|
|
34
|
+
try {
|
|
35
|
+
fs.rmSync(SUITE_TEST_HOME, { recursive: true, force: true });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// Surface but don't fail the run — the temp dir is under os.tmpdir().
|
|
38
|
+
console.error(
|
|
39
|
+
`Failed to remove suite test home ${SUITE_TEST_HOME}: ${
|
|
40
|
+
error instanceof Error ? error.message : String(error)
|
|
41
|
+
}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
});
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { handleError } from "./utils";
|
|
3
|
+
import start from "./commands/start";
|
|
4
|
+
import run from "./commands/run";
|
|
5
|
+
import simulatorList from "./commands/list";
|
|
6
|
+
import simulatorRemove from "./commands/remove";
|
|
7
|
+
|
|
8
|
+
// Re-export the simulator domain types so consumers (e.g. the legacy CLI's
|
|
9
|
+
// utils/simulator-config) can resolve them from the package rather than the
|
|
10
|
+
// now-removed tools/cli/src/simulator/ tree.
|
|
11
|
+
export type {
|
|
12
|
+
SimulatorConfig,
|
|
13
|
+
DeviceConfig,
|
|
14
|
+
AppConfig,
|
|
15
|
+
TwinResponse,
|
|
16
|
+
EventPayload,
|
|
17
|
+
} from "./simulator/types";
|
|
18
|
+
export { AppTypeEnum, TwinTypeEnum } from "./simulator/types";
|
|
19
|
+
export type { SimulatorConnectConfig } from "./utils/simulator-config";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the `simulator` command tree as a standalone commander Command so the
|
|
23
|
+
* legacy CLI (tools/cli) can `program.addCommand(buildSimulatorCommand())` and
|
|
24
|
+
* preserve the `phy simulator …` surface unchanged.
|
|
25
|
+
*/
|
|
26
|
+
export function buildSimulatorCommand() {
|
|
27
|
+
const simulator = new Command("simulator");
|
|
28
|
+
simulator.description("Local device simulator for app development");
|
|
29
|
+
|
|
30
|
+
simulator
|
|
31
|
+
.command("start")
|
|
32
|
+
.description("Start the local simulator server")
|
|
33
|
+
.option("-p, --port <port>", "Port to listen on", "55000")
|
|
34
|
+
.option("--connect <name>", "Connect to phyhub as a provisioned device")
|
|
35
|
+
.action(handleError(start));
|
|
36
|
+
|
|
37
|
+
simulator
|
|
38
|
+
.command("run <path>")
|
|
39
|
+
.description(
|
|
40
|
+
"Run an app connected to the simulator (reconciles twin, spawns dev server)",
|
|
41
|
+
)
|
|
42
|
+
.option("--type <type>", "Override app type detection (screen or edge)")
|
|
43
|
+
.option(
|
|
44
|
+
"--dev-command <command>",
|
|
45
|
+
"Override dev command (default: npm run dev)",
|
|
46
|
+
)
|
|
47
|
+
.option(
|
|
48
|
+
"--settings-dir <path>",
|
|
49
|
+
"Settings directory path (default: src/settings)",
|
|
50
|
+
)
|
|
51
|
+
.action(handleError(run));
|
|
52
|
+
|
|
53
|
+
simulator
|
|
54
|
+
.command("list")
|
|
55
|
+
.description("List all provisioned simulator devices")
|
|
56
|
+
.action(simulatorList);
|
|
57
|
+
|
|
58
|
+
simulator
|
|
59
|
+
.command("remove <name>")
|
|
60
|
+
.description("Remove a saved simulator device")
|
|
61
|
+
.action(simulatorRemove);
|
|
62
|
+
|
|
63
|
+
return simulator;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default buildSimulatorCommand;
|