-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathgithub-actions.test.mjs
More file actions
452 lines (416 loc) · 14.1 KB
/
Copy pathgithub-actions.test.mjs
File metadata and controls
452 lines (416 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
import assert from "node:assert/strict";
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
const iosAction = readFileSync(
new URL("../actions/run-ios-comment-session/action.yml", import.meta.url),
"utf8",
);
const androidAction = readFileSync(
new URL("../actions/run-android-comment-session/action.yml", import.meta.url),
"utf8",
);
const ciWorkflow = readFileSync(
new URL("../.github/workflows/ci.yml", import.meta.url),
"utf8",
);
const packageJson = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
);
const cliWrapper = readFileSync(
new URL("../packages/cli/bin/simdeck.mjs", import.meta.url),
"utf8",
);
const androidIntegration = readFileSync(
new URL("../scripts/integration/android.mjs", import.meta.url),
"utf8",
);
function indexOfStep(action, name) {
const index = action.indexOf(`- name: ${name}`);
assert.notEqual(index, -1, `${name} step should exist`);
return index;
}
function stepSlice(action, name, nextName) {
const startIndex = indexOfStep(action, name);
const endIndex =
nextName === undefined ? action.length : indexOfStep(action, nextName);
assert(endIndex > startIndex, `${nextName} should run after ${name}`);
return action.slice(startIndex, endIndex);
}
const darwinTest = process.platform === "darwin" ? test : test.skip;
test("npm package declares Windows Android host support", () => {
assert.ok(packageJson.os.includes("darwin"));
assert.ok(packageJson.os.includes("linux"));
assert.ok(packageJson.os.includes("win32"));
assert.ok(packageJson.files.includes("build/simdeck-bin-win32-x64.exe"));
});
test("npm CLI wrapper resolves Windows x64 native binary", () => {
assert.match(cliWrapper, /win32-x64/);
assert.match(cliWrapper, /simdeck-bin-win32-x64\.exe/);
});
test("CI runs Android emulator integration on Linux and Windows", () => {
assert.match(ciWorkflow, /integration-android:/);
assert.match(ciWorkflow, /os:\s*\[\s*ubuntu-latest,\s*windows-latest\s*\]/);
assert.match(ciWorkflow, /matrix\.os/);
assert.match(ciWorkflow, /SimDeck_Pixel_CI/);
assert.match(ciWorkflow, /test:integration:android/);
});
test("Windows Android CI boot path is bounded and diagnostic", () => {
const windowsBootStep = stepSlice(
ciWorkflow,
"Create, boot, and test Android emulator (Windows)",
"Stop Android emulator (Windows)",
);
assert.match(windowsBootStep, /\$accelSupported = \$LASTEXITCODE -eq 0/);
assert.match(windowsBootStep, /google_atd/);
assert.match(windowsBootStep, /"-qt-hide-window"/);
assert.match(windowsBootStep, /"-feature", "-Vulkan"/);
assert.match(windowsBootStep, /"-accel", "on"/);
assert.match(windowsBootStep, /"-accel", "off"/);
assert.match(windowsBootStep, /\$serial = "emulator-5554"/);
assert.match(windowsBootStep, /\$devices -match "\$serial\\s\+device"/);
assert.doesNotMatch(windowsBootStep, /device\|offline/);
assert.match(windowsBootStep, /-RedirectStandardOutput \$stdout/);
assert.match(windowsBootStep, /Write-EmulatorDiagnostics/);
assert.match(
windowsBootStep,
/deviceDeadline = \(Get-Date\)\.AddMinutes\(10\)/,
);
assert.match(
windowsBootStep,
/\$env:SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID = "1"/,
);
assert.match(windowsBootStep, /npm run test:integration:android/);
assert.doesNotMatch(windowsBootStep, /wait-for-device/);
});
test("Android integration runner resolves Windows executables", () => {
assert.match(androidIntegration, /fileURLToPath/);
assert.doesNotMatch(
androidIntegration,
/new URL\("\.\.\/\.\.", import\.meta\.url\)\.pathname/,
);
assert.match(androidIntegration, /runningAndroidAvds\(avdName\)/);
assert.match(
androidIntegration,
/fallbackAvdName && avds\.size === 0 && devices\.length === 1/,
);
assert.match(androidIntegration, /resolveAndroidLaunchTarget/);
assert.match(androidIntegration, /query-activities/);
assert.match(androidIntegration, /isAndroidIntentUnavailable/);
assert.match(androidIntegration, /simdeck-bin\.exe/);
assert.match(androidIntegration, /simdeck-bin-win32-x64\.exe/);
assert.match(androidIntegration, /AppData", "Local", "Android", "Sdk/);
assert.match(androidIntegration, /\.exe/);
});
test("Android PR comment forwards configured emulator startup args", () => {
assert.match(androidAction, /android_emulator_args:/);
assert.match(androidAction, /INPUT_ANDROID_EMULATOR_ARGS_VALUE/);
const bootStep = stepSlice(
androidAction,
"Boot Android emulator",
"Update status comment with booted emulator URL",
);
assert.match(bootStep, /SIMDECK_ANDROID_EMULATOR_ARGS/);
assert.match(bootStep, /--android-emulator-arg=/);
assert.match(bootStep, /simdeck --server-url .* boot "\$\{udid\}"/);
});
test("iOS PR comment waits for public simulator list access", () => {
const prebootIndex = iosAction.indexOf(
"- name: Select and preboot simulator",
);
const readinessIndex = iosAction.indexOf(
"- name: Wait for public SimDeck iOS session access",
);
const commentIndex = iosAction.indexOf(
"- name: Update status comment with booted simulator URL",
);
assert.notEqual(prebootIndex, -1, "preboot step should exist");
assert.notEqual(
commentIndex,
-1,
"booted simulator comment step should exist",
);
assert(
readinessIndex > prebootIndex,
"readiness check should run after simulator preboot",
);
assert(
readinessIndex < commentIndex,
"readiness check should run before posting the PR URL",
);
const readinessStep = iosAction.slice(readinessIndex, commentIndex);
assert.match(
readinessStep,
/\$\{\{ steps\.stream\.outputs\.url \}\}\/api\/simulators\?simdeckToken=/,
"readiness check should use the public tunnel URL",
);
assert.match(
readinessStep,
/SIMULATOR_UDID/,
"readiness check should look for the selected simulator",
);
assert.match(
readinessStep,
/isBooted/,
"readiness check should require the selected simulator to be booted",
);
});
for (const [platform, action, startStep, waitStep] of [
[
"iOS",
iosAction,
"Start simulator artifact download",
"Wait for simulator artifact download",
],
[
"Android",
androidAction,
"Start APK artifact download",
"Wait for APK artifact download",
],
]) {
test(`${platform} PR comment resolves an actual matching artifact before download`, () => {
const artifactStep = stepSlice(action, startStep, waitStep);
assert.match(
artifactStep,
/artifact_candidates\+=\$'\\n'"\$\{ARTIFACT_PREFIX\}"/,
"default artifact lookup should include prefix-only artifacts",
);
assert.match(
artifactStep,
/run\.get\("head_sha"\) == sha/,
"repository artifact lookup should match the PR head SHA",
);
assert.match(
artifactStep,
/find_artifact_by_run/,
"workflow-run fallback should inspect the run's artifacts",
);
assert.match(
artifactStep,
/--name "\$\{download_artifact_name\}"/,
"download should use the artifact name that was actually found",
);
assert.doesNotMatch(
artifactStep,
/gh run download "\$\{run_id\}" --repo "\$\{REPO\}" --name "\$\{artifact_name\}"/,
"workflow-run fallback must not assume the generated artifact name exists",
);
});
test(`${platform} PR comment reports artifact startup failure explicitly`, () => {
const waitStepBody = stepSlice(action, waitStep, "Install and launch");
assert.match(
waitStepBody,
/SIMDECK_SESSION_START_FAILED=1/,
"artifact failure should mark startup failure",
);
assert.match(
waitStepBody,
/session could not start for commit/,
"artifact failure comment should not read like a completed session",
);
assert.match(
waitStepBody,
/No unexpired .* artifact was available/,
"artifact failure comment should explain the missing or expired artifact",
);
});
test(`${platform} PR comment only posts ended status after app launch`, () => {
const launchIndex = indexOfStep(action, "Install and launch");
const sessionOpenIndex = action.indexOf("SIMDECK_SESSION_OPEN=1");
const finalStep = stepSlice(action, "Update status comment at end");
assert(
sessionOpenIndex > launchIndex,
"session should only be marked open after the app is launched",
);
assert.match(
finalStep,
/if: always\(\) && env\.SIMDECK_SESSION_OPEN == '1'/,
"ended status should only run for sessions that opened",
);
});
test(`${platform} PR comment relies on the packaged CLI service supervisor`, () => {
const startStepBody = stepSlice(
action,
"Install tools, start SimDeck and tunnel",
"Resolve PR head",
);
assert.doesNotMatch(
startStepBody,
/simdeck-service-supervisor\.sh/,
"action should not carry a second workflow-local service supervisor",
);
assert.match(startStepBody, /simdeck service run/);
assert.match(startStepBody, /echo "\$!" > simdeck\.pid/);
});
test(`${platform} PR comment keepalive tolerates transient service restarts`, () => {
const keepaliveStepBody = stepSlice(
action,
"Keep session alive",
"Stop session",
);
assert.match(
keepaliveStepBody,
/SIMDECK_SERVICE_HEALTH_GRACE_SECONDS/,
"keepalive should have a grace window for service restarts",
);
assert.match(
keepaliveStepBody,
/health_failure_started/,
"keepalive should track continuous service health failures",
);
assert.match(
keepaliveStepBody,
/cat simdeck-service\.log/,
"keepalive should print service logs when the grace window expires",
);
assert.match(
keepaliveStepBody,
/continue/,
"keepalive should continue polling after transient service failures",
);
});
test(`${platform} PR comment uses the public session URL output`, () => {
assert.match(
action,
/session_password:/,
"action should expose a session password input",
);
assert.match(
action,
/ci_proxy_url:/,
"action should expose a CI proxy URL input",
);
assert.match(
action,
/default: https:\/\/ci\.simdeck\.sh/,
"CI proxy links should default to the production SimDeck domain",
);
assert.match(
action,
/proxy_links:/,
"action should expose a proxy_links input",
);
assert.match(
action,
/default: "true"/,
"proxy_links should default to true",
);
assert.match(
action,
/public_url=/,
"stream step should build a public URL output",
);
assert.match(
action,
/steps\.stream\.outputs\.public_url/,
"PR comments should use the public URL output",
);
assert.match(
action,
/session_password requires proxy_links: true/,
"password-protected sessions should not allow raw tunnel links",
);
});
}
darwinTest(
"npm CLI wrapper restarts service run after recoverable native exit",
() => {
const root = mkdtempSync(join(tmpdir(), "simdeck-wrapper-test-"));
try {
mkdirSync(join(root, "bin"), { recursive: true });
mkdirSync(join(root, "build"), { recursive: true });
const wrapperPath = join(root, "bin", "simdeck.mjs");
const nativePath = join(root, "build", "simdeck-bin");
const logPath = join(root, "native.log");
const countPath = join(root, "count");
copyFileSync(
new URL("../packages/cli/bin/simdeck.mjs", import.meta.url),
wrapperPath,
);
chmodSync(wrapperPath, 0o755);
writeFileSync(
nativePath,
`#!/usr/bin/env bash
set -euo pipefail
count="$(cat "${countPath}" 2>/dev/null || echo 0)"
count="$((count + 1))"
echo "$count" > "${countPath}"
echo "$$:\${SIMDECK_SERVICE_METADATA_PID:-}:\$*" >> "${logPath}"
if [[ "$count" == "1" ]]; then
exit 75
fi
exit 0
`,
);
chmodSync(nativePath, 0o755);
const result = spawnSync(
process.execPath,
[wrapperPath, "service", "run", "--port", "4310"],
{
encoding: "utf8",
},
);
assert.equal(result.status, 0, result.stderr);
const logLines = readFileSync(logPath, "utf8").trim().split("\n");
assert.equal(logLines.length, 2, "service run should be retried once");
const entries = logLines.map((line) => {
const [pid, metadataPid, args] = line.split(":");
return { pid, metadataPid, args };
});
assert.notEqual(entries[0].pid, entries[1].pid);
assert.match(entries[0].metadataPid, /^\d+$/);
assert.equal(entries[0].metadataPid, entries[1].metadataPid);
assert.notEqual(entries[0].pid, entries[0].metadataPid);
assert.equal(entries[0].args, "service run --port 4310");
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
darwinTest(
"npm CLI wrapper does not restart non-service commands on exit 75",
() => {
const root = mkdtempSync(join(tmpdir(), "simdeck-wrapper-test-"));
try {
mkdirSync(join(root, "bin"), { recursive: true });
mkdirSync(join(root, "build"), { recursive: true });
const wrapperPath = join(root, "bin", "simdeck.mjs");
const nativePath = join(root, "build", "simdeck-bin");
const logPath = join(root, "native.log");
copyFileSync(
new URL("../packages/cli/bin/simdeck.mjs", import.meta.url),
wrapperPath,
);
chmodSync(wrapperPath, 0o755);
writeFileSync(
nativePath,
`#!/usr/bin/env bash
set -euo pipefail
echo "$$:\${SIMDECK_SERVICE_METADATA_PID:-}:\$*" >> "${logPath}"
exit 75
`,
);
chmodSync(nativePath, 0o755);
const result = spawnSync(process.execPath, [wrapperPath, "list"], {
encoding: "utf8",
});
assert.equal(result.status, 75);
const logLines = readFileSync(logPath, "utf8").trim().split("\n");
assert.equal(logLines.length, 1);
assert.equal(logLines[0].split(":")[1], "");
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);