dsh-plugin-rea 2.0.2 → 2.0.3
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 +92 -0
- package/README.md +16 -7
- package/README.zh-CN.md +15 -6
- package/assets/make-screenshots.ps1 +2 -2
- package/assets/screenshot-1.png +0 -0
- package/bin/rea-shim.mjs +112 -19
- package/check-updates.ps1 +90 -15
- package/docs/bundle-design.md +6 -5
- package/manifest/bundle.manifest.json +48 -41
- package/package.json +2 -3
- package/scripts/audit-manifest.mjs +98 -0
- package/scripts/rea-bundle-bootstrap.ps1 +37 -10
- package/scripts/setup.mjs +352 -87
- package/scripts/setup.test.mjs +683 -4
- package/examples/cordis.patch.posix.yml +0 -18
- package/examples/cordis.patch.with-env.yml +0 -42
package/scripts/setup.test.mjs
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* Runs with `node --test`. No network, no filesystem writes outside a temp dir.
|
|
12
12
|
*/
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { spawnSync } from "node:child_process";
|
|
15
|
-
import {
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { createServer } from "node:http";
|
|
16
18
|
import { tmpdir } from "node:os";
|
|
17
19
|
import path from "node:path";
|
|
18
20
|
import test from "node:test";
|
|
@@ -319,7 +321,17 @@ function runSetupNodeOnly(setupPath, root, env) {
|
|
|
319
321
|
});
|
|
320
322
|
}
|
|
321
323
|
|
|
322
|
-
test("--reuse still adopts a compatible node", () => {
|
|
324
|
+
test("--reuse still adopts a compatible node", (t) => {
|
|
325
|
+
// The candidate is the host's own interpreter, so this test only means anything
|
|
326
|
+
// where the host satisfies REA's engines: on a Node 20 runner it fails for a
|
|
327
|
+
// reason that has nothing to do with the reuse check (the check is doing its
|
|
328
|
+
// job), while CI's pinned Node 22 never notices.
|
|
329
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
330
|
+
const compatible = (major === 22 && minor >= 19) || (major === 24 && minor >= 11) || major >= 26;
|
|
331
|
+
if (!compatible) {
|
|
332
|
+
t.skip(`host Node ${process.versions.node} is outside REA's engines; the candidate would be rejected on purpose`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
323
335
|
const root = mkdtempSync(path.join(tmpdir(), "rea-node-ok-"));
|
|
324
336
|
try {
|
|
325
337
|
const run = runSetupNodeOnly(SETUP_SOURCE, root, { REA_BUNDLE_NODE: process.execPath });
|
|
@@ -395,7 +407,7 @@ test("an unavailable optional component is reported, not fatal", () => {
|
|
|
395
407
|
mkdirSync(stub, { recursive: true });
|
|
396
408
|
writeFileSync(
|
|
397
409
|
path.join(stub, "rea.mjs"),
|
|
398
|
-
'if (process.argv.includes("--version")) { console.log("6.
|
|
410
|
+
'if (process.argv.includes("--version")) { console.log("6.5.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
|
|
399
411
|
);
|
|
400
412
|
|
|
401
413
|
const run = spawnSync(process.execPath, [SETUP_SOURCE, "--offline", "--only", "androidPlatformTools", "--root", root], {
|
|
@@ -416,3 +428,670 @@ test("an unavailable optional component is reported, not fatal", () => {
|
|
|
416
428
|
rmSync(root, { recursive: true, force: true });
|
|
417
429
|
}
|
|
418
430
|
});
|
|
431
|
+
|
|
432
|
+
/* =========================== this round's regressions =========================
|
|
433
|
+
*
|
|
434
|
+
* Each test below pins a defect that was verified in the code before it was
|
|
435
|
+
* changed. They run setup.mjs itself (never a mirror of it) against a throwaway
|
|
436
|
+
* plugin tree, so the whole wiring — manifest → download → place → wire — is the
|
|
437
|
+
* real one. Nothing here reaches the network: a component is either satisfied
|
|
438
|
+
* from a primed cache, or its download is *supposed* to return before opening a
|
|
439
|
+
* socket.
|
|
440
|
+
*/
|
|
441
|
+
|
|
442
|
+
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
443
|
+
const REPO_SHIM = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
|
|
444
|
+
|
|
445
|
+
/** A throwaway plugin tree: setup.mjs, the real manifest, and the shim. */
|
|
446
|
+
function makePluginTree(root, mutateManifest) {
|
|
447
|
+
const plugin = path.join(root, "plugin");
|
|
448
|
+
mkdirSync(path.join(plugin, "scripts"), { recursive: true });
|
|
449
|
+
mkdirSync(path.join(plugin, "manifest"), { recursive: true });
|
|
450
|
+
mkdirSync(path.join(plugin, "bin"), { recursive: true });
|
|
451
|
+
copyFileSync(SETUP_SOURCE, path.join(plugin, "scripts", "setup.mjs"));
|
|
452
|
+
copyFileSync(REPO_SHIM, path.join(plugin, "bin", "rea-shim.mjs"));
|
|
453
|
+
const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
|
|
454
|
+
if (mutateManifest) mutateManifest(manifest);
|
|
455
|
+
writeFileSync(path.join(plugin, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
456
|
+
return path.join(plugin, "scripts", "setup.mjs");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** A `file`-kind component, used to replace a real one in a test manifest. */
|
|
460
|
+
function testComponent(url, payload, extra = {}) {
|
|
461
|
+
return {
|
|
462
|
+
description: "test vehicle",
|
|
463
|
+
kind: "file",
|
|
464
|
+
license: "test",
|
|
465
|
+
requires: extra.requires ?? [],
|
|
466
|
+
optional: extra.optional ?? false,
|
|
467
|
+
probe: extra.probe ?? {},
|
|
468
|
+
entries: {
|
|
469
|
+
all: [
|
|
470
|
+
{
|
|
471
|
+
id: "payload",
|
|
472
|
+
version: "1",
|
|
473
|
+
url,
|
|
474
|
+
format: "file",
|
|
475
|
+
size: payload.length,
|
|
476
|
+
sha256: createHash("sha256").update(payload).digest("hex"),
|
|
477
|
+
moveTo: extra.moveTo ?? "payload",
|
|
478
|
+
saveAs: extra.saveAs ?? "payload.bin",
|
|
479
|
+
...(extra.executable ? { executable: true } : {}),
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** A stub vendored CLI, so the probe/shim stages have something to run. */
|
|
487
|
+
function addReaStub(bundleRoot, version = "6.5.0") {
|
|
488
|
+
const dir = path.join(bundleRoot, "vendor", "node_modules", "rea-agents", "scripts");
|
|
489
|
+
mkdirSync(dir, { recursive: true });
|
|
490
|
+
const identity = JSON.stringify({ cli_package_version: version, catalog: { counts: { mcp_tools: 206 } } });
|
|
491
|
+
writeFileSync(
|
|
492
|
+
path.join(dir, "rea.mjs"),
|
|
493
|
+
'if (process.argv.includes("--version")) { console.log(' + JSON.stringify(version) + '); }\n' +
|
|
494
|
+
"else { console.log(JSON.stringify({ healthy: true, checks: [], identity: " + identity + " })); }\n",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function runToCompletion(command, args, options) {
|
|
499
|
+
return new Promise((resolve) => {
|
|
500
|
+
const child = spawn(command, args, options);
|
|
501
|
+
let stdout = "";
|
|
502
|
+
let stderr = "";
|
|
503
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
504
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
505
|
+
child.on("error", (error) => resolve({ status: -1, stdout, stderr: String(error) }));
|
|
506
|
+
child.on("close", (code) => resolve({ status: code, stdout, stderr }));
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function waitFor(predicate, timeoutMs = 8000) {
|
|
511
|
+
const deadline = Date.now() + timeoutMs;
|
|
512
|
+
for (;;) {
|
|
513
|
+
if (predicate()) return true;
|
|
514
|
+
if (Date.now() > deadline) return false;
|
|
515
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* ------------------------------- --only keeps what it did not touch -------- */
|
|
520
|
+
|
|
521
|
+
test("--only keeps the records of the components it did not touch", () => {
|
|
522
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-only-keep-"));
|
|
523
|
+
try {
|
|
524
|
+
const bundle = path.join(root, "bundle");
|
|
525
|
+
mkdirSync(bundle, { recursive: true });
|
|
526
|
+
const jdkHome = path.join(root, "jdk-home");
|
|
527
|
+
mkdirSync(jdkHome, { recursive: true });
|
|
528
|
+
// A provisioned machine: the JDK and Node are recorded, and only adb is being
|
|
529
|
+
// topped up. Writing bundle.json from this run's results alone erased the
|
|
530
|
+
// other two, so the shim stopped exporting JAVA_HOME/GHIDRA_INSTALL_DIR.
|
|
531
|
+
writeFileSync(
|
|
532
|
+
path.join(bundle, "bundle.json"),
|
|
533
|
+
JSON.stringify(
|
|
534
|
+
{
|
|
535
|
+
schema: 1,
|
|
536
|
+
paths: { node: process.execPath, jdk: jdkHome, ghidra: null, adb: null },
|
|
537
|
+
versions: { reaAgents: "6.5.0", node: process.versions.node, jdk: "21" },
|
|
538
|
+
components: {
|
|
539
|
+
node: { path: process.execPath, version: process.versions.node },
|
|
540
|
+
jdk: { path: jdkHome, version: "21" },
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
null,
|
|
544
|
+
2,
|
|
545
|
+
) + "\n",
|
|
546
|
+
);
|
|
547
|
+
addReaStub(bundle);
|
|
548
|
+
const setup = makePluginTree(root, (manifest) => {
|
|
549
|
+
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional: true });
|
|
550
|
+
});
|
|
551
|
+
const run = spawnSync(process.execPath, [setup, "--offline", "--only", "androidPlatformTools", "--root", bundle], {
|
|
552
|
+
encoding: "utf8",
|
|
553
|
+
timeout: 120000,
|
|
554
|
+
});
|
|
555
|
+
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
|
556
|
+
const written = JSON.parse(readFileSync(path.join(bundle, "bundle.json"), "utf8"));
|
|
557
|
+
assert.equal(written.components.jdk.path, jdkHome, "the jdk record must survive an --only run");
|
|
558
|
+
assert.equal(written.paths.jdk, jdkHome, "and so must the path the shim exports as JAVA_HOME");
|
|
559
|
+
assert.equal(written.components.node.path, process.execPath);
|
|
560
|
+
assert.equal(written.paths.node, process.execPath);
|
|
561
|
+
// The component that did run failed (offline), and stays unavailable: the
|
|
562
|
+
// merge must not invent a path for it.
|
|
563
|
+
assert.equal(written.paths.adb, null);
|
|
564
|
+
assert.equal(written.components.androidPlatformTools.unavailable, true);
|
|
565
|
+
} finally {
|
|
566
|
+
rmSync(root, { recursive: true, force: true });
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("--only satisfies a requirement from the record instead of skipping the check", () => {
|
|
571
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-only-requires-"));
|
|
572
|
+
try {
|
|
573
|
+
const bundle = path.join(root, "bundle");
|
|
574
|
+
mkdirSync(bundle, { recursive: true });
|
|
575
|
+
const jdkHome = path.join(root, "jdk-home");
|
|
576
|
+
mkdirSync(jdkHome, { recursive: true });
|
|
577
|
+
const setup = makePluginTree(root, () => {});
|
|
578
|
+
const args = (extra) => [setup, "--reuse", "--dry-run", "--only", "apktool", "--root", bundle, ...extra];
|
|
579
|
+
// JAVA_HOME is also a way "the machine" answers this requirement, so it is
|
|
580
|
+
// cleared: the first case needs the requirement to be genuinely unavailable.
|
|
581
|
+
const env = { ...process.env, JAVA_HOME: "" };
|
|
582
|
+
|
|
583
|
+
// No record at all: the requirement is genuinely unavailable, and that must be
|
|
584
|
+
// reported exactly as a full run reports it.
|
|
585
|
+
const missing = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
|
|
586
|
+
assert.match(missing.stdout, /required component "jdk" is unavailable/);
|
|
587
|
+
|
|
588
|
+
// With a recorded JDK the requirement is answered by the record, so apktool
|
|
589
|
+
// plans instead of failing on a vendor/jdk that --only never provisions.
|
|
590
|
+
writeFileSync(
|
|
591
|
+
path.join(bundle, "bundle.json"),
|
|
592
|
+
JSON.stringify({ schema: 1, paths: { jdk: jdkHome }, versions: { jdk: "21" }, components: { jdk: { path: jdkHome, version: "21" } } }, null, 2) + "\n",
|
|
593
|
+
);
|
|
594
|
+
const recorded = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
|
|
595
|
+
assert.equal(recorded.status, 0, `${recorded.stdout}\n${recorded.stderr}`);
|
|
596
|
+
assert.doesNotMatch(recorded.stdout, /required component "jdk" is unavailable/);
|
|
597
|
+
assert.match(recorded.stdout, /\[plan\] apktool/);
|
|
598
|
+
// And `--only reaAgents` on a fresh root must still work: the node it needs is
|
|
599
|
+
// the interpreter running setup, which is what provisionReaAgents already uses.
|
|
600
|
+
const fresh = path.join(root, "fresh");
|
|
601
|
+
const reaOnly = spawnSync(process.execPath, [setup, "--dry-run", "--only", "reaAgents", "--root", fresh], { encoding: "utf8", timeout: 120000, env });
|
|
602
|
+
assert.equal(reaOnly.status, 0, `${reaOnly.stdout}\n${reaOnly.stderr}`);
|
|
603
|
+
assert.doesNotMatch(reaOnly.stdout, /required component "node" is unavailable/);
|
|
604
|
+
} finally {
|
|
605
|
+
rmSync(root, { recursive: true, force: true });
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
/* ------------------------------------------- a complete .part is finished work */
|
|
610
|
+
|
|
611
|
+
test("a complete .part is verified and kept instead of re-requested", () => {
|
|
612
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-part-complete-"));
|
|
613
|
+
try {
|
|
614
|
+
const bundle = path.join(root, "bundle");
|
|
615
|
+
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
|
616
|
+
const payload = Buffer.from("the whole file, already on disk\n");
|
|
617
|
+
// The URL points at a closed port on purpose: the fixed code must complete
|
|
618
|
+
// without opening a socket. Before the fix it sent `Range: bytes=<size>-`,
|
|
619
|
+
// which a real server answers with 416, and four attempts later it reported a
|
|
620
|
+
// download that was already finished as failed.
|
|
621
|
+
const setup = makePluginTree(root, (manifest) => {
|
|
622
|
+
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", payload);
|
|
623
|
+
});
|
|
624
|
+
addReaStub(bundle);
|
|
625
|
+
writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload);
|
|
626
|
+
|
|
627
|
+
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
|
|
628
|
+
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
|
629
|
+
assert.match(run.stdout, /was already complete; verified and kept/);
|
|
630
|
+
assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
|
|
631
|
+
} finally {
|
|
632
|
+
rmSync(root, { recursive: true, force: true });
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
/* ------------------------------------------------- the PowerShell route resumes */
|
|
637
|
+
|
|
638
|
+
test("the PowerShell route resumes from the bytes already on disk", async (t) => {
|
|
639
|
+
if (process.platform !== "win32") {
|
|
640
|
+
// The Node route pins TLS (`httpsRequest`) whatever the URL scheme says, so a
|
|
641
|
+
// plain-HTTP fixture can only exercise the PowerShell route — which is also the
|
|
642
|
+
// Windows default and the one the resume defect was in.
|
|
643
|
+
t.skip("the PowerShell download route only exists on Windows");
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const payload = Buffer.alloc(1024 * 1024, 11);
|
|
647
|
+
const alreadyHave = 256 * 1024;
|
|
648
|
+
const seen = [];
|
|
649
|
+
const server = createServer((req, res) => {
|
|
650
|
+
req.on("error", () => {});
|
|
651
|
+
res.on("error", () => {});
|
|
652
|
+
const range = req.headers.range ?? null;
|
|
653
|
+
seen.push(range);
|
|
654
|
+
const start = range ? Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0) : 0;
|
|
655
|
+
if (range && start >= payload.length) {
|
|
656
|
+
res.writeHead(416);
|
|
657
|
+
res.end();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const slice = payload.subarray(start);
|
|
661
|
+
const headers = { "content-type": "application/octet-stream", "content-length": String(slice.length) };
|
|
662
|
+
if (start > 0) headers["content-range"] = `bytes ${start}-${payload.length - 1}/${payload.length}`;
|
|
663
|
+
res.writeHead(start > 0 ? 206 : 200, headers);
|
|
664
|
+
res.end(slice);
|
|
665
|
+
});
|
|
666
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
667
|
+
const port = server.address().port;
|
|
668
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-ps-resume-"));
|
|
669
|
+
try {
|
|
670
|
+
const bundle = path.join(root, "bundle");
|
|
671
|
+
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
|
672
|
+
const setup = makePluginTree(root, (manifest) => {
|
|
673
|
+
manifest.components.androidPlatformTools = testComponent(`http://127.0.0.1:${port}/payload.bin`, payload);
|
|
674
|
+
});
|
|
675
|
+
addReaStub(bundle);
|
|
676
|
+
writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload.subarray(0, alreadyHave));
|
|
677
|
+
|
|
678
|
+
const run = await runToCompletion(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
|
|
679
|
+
encoding: "utf8",
|
|
680
|
+
timeout: 120000,
|
|
681
|
+
});
|
|
682
|
+
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
|
683
|
+
assert.ok(
|
|
684
|
+
seen.some((header) => header && Number(/bytes=(\d+)-/.exec(header)?.[1]) === alreadyHave),
|
|
685
|
+
`expected a resume request starting at ${alreadyHave}; saw ${JSON.stringify(seen)}`,
|
|
686
|
+
);
|
|
687
|
+
assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
|
|
688
|
+
} finally {
|
|
689
|
+
await new Promise((resolve) => server.close(resolve));
|
|
690
|
+
rmSync(root, { recursive: true, force: true });
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
/* ------------------------------------------------- a wrapper must be executable */
|
|
695
|
+
|
|
696
|
+
test("a wrapper marked executable lands with the exec bit on POSIX", (t) => {
|
|
697
|
+
if (process.platform === "win32") {
|
|
698
|
+
t.skip("POSIX exec bits do not exist on Windows; the flag is honoured but meaningless there");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-exec-bit-"));
|
|
702
|
+
try {
|
|
703
|
+
const bundle = path.join(root, "bundle");
|
|
704
|
+
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
|
705
|
+
const marked = Buffer.from("#!/bin/sh\necho wrapper\n");
|
|
706
|
+
const plain = Buffer.from("not a script\n");
|
|
707
|
+
const setup = makePluginTree(root, (manifest) => {
|
|
708
|
+
const component = testComponent("http://127.0.0.1:9/wrapper.sh", marked, { executable: true, saveAs: "wrapper.sh", moveTo: "tool" });
|
|
709
|
+
component.entries.all.push({
|
|
710
|
+
id: "plain",
|
|
711
|
+
version: "1",
|
|
712
|
+
url: "http://127.0.0.1:9/plain.sh",
|
|
713
|
+
format: "file",
|
|
714
|
+
size: plain.length,
|
|
715
|
+
sha256: createHash("sha256").update(plain).digest("hex"),
|
|
716
|
+
moveTo: "tool",
|
|
717
|
+
saveAs: "plain.sh",
|
|
718
|
+
});
|
|
719
|
+
manifest.components.androidPlatformTools = component;
|
|
720
|
+
});
|
|
721
|
+
addReaStub(bundle);
|
|
722
|
+
// Prime the cache so no socket is opened and the mode is the only variable.
|
|
723
|
+
writeFileSync(path.join(bundle, "cache", "wrapper.sh"), marked);
|
|
724
|
+
writeFileSync(path.join(bundle, "cache", "plain.sh"), plain);
|
|
725
|
+
|
|
726
|
+
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
|
|
727
|
+
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
|
728
|
+
const wrapperMode = statSync(path.join(bundle, "vendor", "tool", "wrapper.sh")).mode;
|
|
729
|
+
const plainMode = statSync(path.join(bundle, "vendor", "tool", "plain.sh")).mode;
|
|
730
|
+
assert.notEqual(wrapperMode & 0o111, 0, `the marked wrapper must be executable, mode ${wrapperMode.toString(8)}`);
|
|
731
|
+
assert.equal(plainMode & 0o111, 0, "an unmarked file must keep the mode the archive gave it");
|
|
732
|
+
} finally {
|
|
733
|
+
rmSync(root, { recursive: true, force: true });
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
/* ------------------------------------------- a file probe's expect is honoured */
|
|
738
|
+
|
|
739
|
+
test("a file probe honours expect.contains", () => {
|
|
740
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-file-probe-"));
|
|
741
|
+
try {
|
|
742
|
+
const make = (content) => {
|
|
743
|
+
const bundle = path.join(root, `bundle-${content.length}`);
|
|
744
|
+
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
|
745
|
+
const payload = Buffer.from(content);
|
|
746
|
+
const component = testComponent("http://127.0.0.1:9/marker.txt", payload, { saveAs: "marker.txt", moveTo: "probe" });
|
|
747
|
+
component.probe = { file: "{vendor}/probe/marker.txt", expect: { contains: "application.version=" } };
|
|
748
|
+
const setup = makePluginTree(path.join(root, `tree-${content.length}`), (manifest) => {
|
|
749
|
+
manifest.components.androidPlatformTools = component;
|
|
750
|
+
});
|
|
751
|
+
addReaStub(bundle);
|
|
752
|
+
writeFileSync(path.join(bundle, "cache", "marker.txt"), payload);
|
|
753
|
+
return spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
|
|
754
|
+
};
|
|
755
|
+
const ok = make("application.version=12.1.4\n");
|
|
756
|
+
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
|
|
757
|
+
// The file exists either way; only its content decides. Before the fix the
|
|
758
|
+
// `expect` on a file probe was dead configuration and this case passed.
|
|
759
|
+
const wrong = make("this is not a Ghidra properties file\n");
|
|
760
|
+
assert.equal(wrong.status, 1, `${wrong.stdout}\n${wrong.stderr}`);
|
|
761
|
+
assert.match(wrong.stdout, /does not contain "application\.version="/);
|
|
762
|
+
} finally {
|
|
763
|
+
rmSync(root, { recursive: true, force: true });
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
/* ------------------------------------- a reused engine must pass its own probe */
|
|
768
|
+
|
|
769
|
+
test("a reused component must pass the probe a provisioned copy would face", () => {
|
|
770
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-reuse-probe-"));
|
|
771
|
+
try {
|
|
772
|
+
const install = path.join(root, "ghidra");
|
|
773
|
+
mkdirSync(path.join(install, "support"), { recursive: true });
|
|
774
|
+
mkdirSync(path.join(install, "Ghidra"), { recursive: true });
|
|
775
|
+
writeFileSync(path.join(install, "support", process.platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless"), "");
|
|
776
|
+
const properties = path.join(install, "Ghidra", "application.properties");
|
|
777
|
+
const setup = makePluginTree(root, (manifest) => {
|
|
778
|
+
// The real manifest needs a JDK for ghidra; this test is about the reuse
|
|
779
|
+
// probe, so the requirement is removed rather than faked.
|
|
780
|
+
manifest.components.ghidra.requires = [];
|
|
781
|
+
});
|
|
782
|
+
const bundle = path.join(root, "bundle");
|
|
783
|
+
const args = ["--reuse", "--dry-run", "--only", "ghidra", "--root", bundle];
|
|
784
|
+
const env = { ...process.env, GHIDRA_INSTALL_DIR: install };
|
|
785
|
+
|
|
786
|
+
writeFileSync(properties, "application.version=12.1.4\n");
|
|
787
|
+
const good = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
|
|
788
|
+
assert.equal(good.status, 0, `${good.stdout}\n${good.stderr}`);
|
|
789
|
+
assert.match(good.stdout, /reusing existing ghidra from GHIDRA_INSTALL_DIR/);
|
|
790
|
+
|
|
791
|
+
// Adoption used to test only that analyzeHeadless exists, so any directory
|
|
792
|
+
// shaped like a Ghidra install was recorded and the failure surfaced inside REA.
|
|
793
|
+
writeFileSync(properties, "not a Ghidra properties file\n");
|
|
794
|
+
const bad = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
|
|
795
|
+
assert.doesNotMatch(bad.stdout, /reusing existing ghidra/);
|
|
796
|
+
assert.match(bad.stdout, /not reusing GHIDRA_INSTALL_DIR ghidra/);
|
|
797
|
+
} finally {
|
|
798
|
+
rmSync(root, { recursive: true, force: true });
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
/* ------------------------------------------------------------- the shim's lock */
|
|
803
|
+
|
|
804
|
+
function makeBundleTree(root, { marker, lock }) {
|
|
805
|
+
const bundle = path.join(root, "bundle");
|
|
806
|
+
mkdirSync(path.join(bundle, "bin"), { recursive: true });
|
|
807
|
+
mkdirSync(path.join(bundle, "scripts"), { recursive: true });
|
|
808
|
+
mkdirSync(path.join(bundle, "manifest"), { recursive: true });
|
|
809
|
+
copyFileSync(REPO_SHIM, path.join(bundle, "bin", "rea-shim.mjs"));
|
|
810
|
+
writeFileSync(path.join(bundle, "scripts", "setup.mjs"), 'import { writeFileSync } from "node:fs";\nwriteFileSync(' + JSON.stringify(marker) + ', "ran");\n');
|
|
811
|
+
writeFileSync(path.join(bundle, "manifest", "bundle.manifest.json"), "{}\n");
|
|
812
|
+
if (lock !== undefined) writeFileSync(path.join(bundle, "setup.lock"), lock);
|
|
813
|
+
return bundle;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function runShim(bundle, args) {
|
|
817
|
+
const env = { ...process.env, REA_BUNDLE_ROOT: bundle };
|
|
818
|
+
delete env.REA_BUNDLE_AUTO_PROVISION;
|
|
819
|
+
return spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), ...args], { encoding: "utf8", timeout: 60000, env });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
test("a malformed bundle.json starts a provisioning run instead of failing forever", async () => {
|
|
823
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-malformed-"));
|
|
824
|
+
try {
|
|
825
|
+
const marker = path.join(root, "setup-ran");
|
|
826
|
+
const bundle = makeBundleTree(root, { marker, lock: undefined });
|
|
827
|
+
writeFileSync(path.join(bundle, "bundle.json"), "{ this is not json");
|
|
828
|
+
const run = runShim(bundle, ["mcp"]);
|
|
829
|
+
assert.equal(run.status, 78, run.stderr);
|
|
830
|
+
assert.match(run.stderr, /provisioning started in the background/);
|
|
831
|
+
assert.ok(await waitFor(() => existsSync(marker)), "setup was never started for a malformed bundle.json");
|
|
832
|
+
} finally {
|
|
833
|
+
rmSync(root, { recursive: true, force: true });
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("a failed lock is reported as failed, not as provisioning in progress", async () => {
|
|
838
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-failed-lock-"));
|
|
839
|
+
try {
|
|
840
|
+
const marker = path.join(root, "setup-ran");
|
|
841
|
+
const lock = JSON.stringify({ state: "failed", pid: 1, at: Date.now(), exit: 1 });
|
|
842
|
+
const bundle = makeBundleTree(root, { marker, lock });
|
|
843
|
+
// No bundle.json: the "missing" path, which used to print the "provisioning
|
|
844
|
+
// started" sentence for six hours while nothing was running at all.
|
|
845
|
+
const run = runShim(bundle, ["mcp"]);
|
|
846
|
+
assert.equal(run.status, 78, run.stderr);
|
|
847
|
+
assert.match(run.stderr, /the last provisioning attempt failed/);
|
|
848
|
+
assert.doesNotMatch(run.stderr, /provisioning started in the background/);
|
|
849
|
+
assert.equal(existsSync(marker), false, "a fresh failed lock must not trigger another run");
|
|
850
|
+
} finally {
|
|
851
|
+
rmSync(root, { recursive: true, force: true });
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("a lock whose holder is gone does not block provisioning", async () => {
|
|
856
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-dead-lock-"));
|
|
857
|
+
try {
|
|
858
|
+
const marker = path.join(root, "setup-ran");
|
|
859
|
+
const dead = spawnSync(process.execPath, ["-e", ""]);
|
|
860
|
+
assert.ok(Number.isInteger(dead.pid), "could not obtain a pid to outlive");
|
|
861
|
+
const lock = JSON.stringify({ state: "running", pid: dead.pid, at: Date.now() });
|
|
862
|
+
const bundle = makeBundleTree(root, { marker, lock });
|
|
863
|
+
const run = runShim(bundle, ["mcp"]);
|
|
864
|
+
assert.equal(run.status, 78, run.stderr);
|
|
865
|
+
assert.match(run.stderr, /provisioning started in the background/);
|
|
866
|
+
assert.ok(await waitFor(() => existsSync(marker)), "a dead lock holder must not suppress the run");
|
|
867
|
+
} finally {
|
|
868
|
+
rmSync(root, { recursive: true, force: true });
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("an expired lock is cleared instead of wedging provisioning forever", async () => {
|
|
873
|
+
const stale = Date.now() - 7 * 60 * 60 * 1000;
|
|
874
|
+
const dead = spawnSync(process.execPath, ["-e", ""]).pid;
|
|
875
|
+
const cases = {
|
|
876
|
+
"running lock with a dead holder": JSON.stringify({ state: "running", pid: dead, at: stale }),
|
|
877
|
+
"failed lock past the freshness window": JSON.stringify({ state: "failed", pid: dead, at: stale, exit: 1 }),
|
|
878
|
+
"legacy numeric lock": String(stale),
|
|
879
|
+
};
|
|
880
|
+
for (const [label, lock] of Object.entries(cases)) {
|
|
881
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-stale-"));
|
|
882
|
+
try {
|
|
883
|
+
const marker = path.join(root, "setup-ran");
|
|
884
|
+
const bundle = makeBundleTree(root, { marker, lock });
|
|
885
|
+
const run = runShim(bundle, ["mcp"]);
|
|
886
|
+
assert.equal(run.status, 78, `${label}: ${run.stderr}`);
|
|
887
|
+
// Reproduced before the fix: all three cases were silent AND blocked, because
|
|
888
|
+
// the stale lock was never removed and the exclusive create then failed with
|
|
889
|
+
// EEXIST forever. A lock older than the freshness window is not evidence of a
|
|
890
|
+
// run, so it must be dropped and the attempt started again.
|
|
891
|
+
assert.match(run.stderr, /provisioning started in the background/, label);
|
|
892
|
+
assert.ok(await waitFor(() => existsSync(marker)), `${label}: setup was never started`);
|
|
893
|
+
} finally {
|
|
894
|
+
rmSync(root, { recursive: true, force: true });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
/* ------------------------------- --offline is a promise, and npm is the one step
|
|
900
|
+
* that cannot keep it: it is an install, not a download. -------------------- */
|
|
901
|
+
|
|
902
|
+
test("--offline refuses the npm step instead of going online anyway", () => {
|
|
903
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-offline-rea-"));
|
|
904
|
+
try {
|
|
905
|
+
const setup = makePluginTree(root, () => {});
|
|
906
|
+
const bundle = path.join(root, "bundle");
|
|
907
|
+
const run = spawnSync(process.execPath, [setup, "--offline", "--only", "reaAgents", "--root", bundle], {
|
|
908
|
+
encoding: "utf8",
|
|
909
|
+
timeout: 120000,
|
|
910
|
+
});
|
|
911
|
+
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
|
|
912
|
+
assert.match(run.stdout, /--offline and the vendored REA/);
|
|
913
|
+
|
|
914
|
+
// The other direction: a usable vendored copy is still accepted offline, so the
|
|
915
|
+
// flag does not turn into "reaAgents can never be provisioned".
|
|
916
|
+
const second = path.join(root, "bundle2");
|
|
917
|
+
mkdirSync(second, { recursive: true });
|
|
918
|
+
addReaStub(second);
|
|
919
|
+
const ok = spawnSync(process.execPath, [setup, "--offline", "--only", "reaAgents", "--root", second], {
|
|
920
|
+
encoding: "utf8",
|
|
921
|
+
timeout: 120000,
|
|
922
|
+
});
|
|
923
|
+
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
|
|
924
|
+
assert.match(ok.stdout, /already vendored/);
|
|
925
|
+
} finally {
|
|
926
|
+
rmSync(root, { recursive: true, force: true });
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
/* ------------------------------- numeric overrides must not become NaN ------ */
|
|
931
|
+
|
|
932
|
+
test("a non-numeric numeric override falls back to the default", () => {
|
|
933
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-env-"));
|
|
934
|
+
try {
|
|
935
|
+
const setup = makePluginTree(root, () => {});
|
|
936
|
+
const run = (env) =>
|
|
937
|
+
spawnSync(process.execPath, [setup, "--dry-run", "--only", "node", "--root", path.join(root, "bundle")], {
|
|
938
|
+
encoding: "utf8",
|
|
939
|
+
timeout: 120000,
|
|
940
|
+
env: { ...process.env, ...env },
|
|
941
|
+
});
|
|
942
|
+
// `Number("abc")` is NaN, and NaN reached the socket layer as 0-ish behaviour;
|
|
943
|
+
// `REA_BUNDLE_FETCH_RETRIES=abc` once produced "gave up after NaN attempt(s)".
|
|
944
|
+
const bogus = run({ REA_BUNDLE_FETCH_RETRIES: "abc", REA_BUNDLE_IDLE_TIMEOUT_MS: "abc", REA_BUNDLE_CONNECT_TIMEOUT_MS: "abc" });
|
|
945
|
+
assert.equal(bogus.status, 0, `${bogus.stdout}\n${bogus.stderr}`);
|
|
946
|
+
assert.match(bogus.stdout, /retries=4\b/);
|
|
947
|
+
assert.match(bogus.stdout, /idle=120000ms/);
|
|
948
|
+
assert.doesNotMatch(bogus.stdout, /NaN/);
|
|
949
|
+
const explicit = run({ REA_BUNDLE_FETCH_RETRIES: "7" });
|
|
950
|
+
assert.match(explicit.stdout, /retries=7\b/);
|
|
951
|
+
} finally {
|
|
952
|
+
rmSync(root, { recursive: true, force: true });
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
/* ------------------------------- POSIX extraction fallbacks ----------------- */
|
|
957
|
+
|
|
958
|
+
/** An `archive`-kind component, for the extraction paths. */
|
|
959
|
+
function testArchive(url, payload, extra = {}) {
|
|
960
|
+
return {
|
|
961
|
+
description: "test archive",
|
|
962
|
+
kind: "archive",
|
|
963
|
+
license: "test",
|
|
964
|
+
requires: [],
|
|
965
|
+
optional: false,
|
|
966
|
+
unpack: { stripRootComponents: extra.strip ?? 0, moveTo: extra.moveTo ?? "arch" },
|
|
967
|
+
probe: extra.probe ?? {},
|
|
968
|
+
sources: {
|
|
969
|
+
all: {
|
|
970
|
+
version: "1",
|
|
971
|
+
url,
|
|
972
|
+
format: extra.format ?? "zip",
|
|
973
|
+
size: payload.length,
|
|
974
|
+
sha256: createHash("sha256").update(payload).digest("hex"),
|
|
975
|
+
},
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
test("a zip still extracts when unzip is absent (POSIX fallbacks)", (t) => {
|
|
981
|
+
if (process.platform === "win32") {
|
|
982
|
+
t.skip("Windows extracts through tar/Expand-Archive; the POSIX fallbacks do not exist there");
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-extract-"));
|
|
986
|
+
try {
|
|
987
|
+
// A real zip, built with the very tool the fallback will use.
|
|
988
|
+
const zipPath = path.join(root, "fixture.zip");
|
|
989
|
+
const built = spawnSync("python3", [
|
|
990
|
+
"-c",
|
|
991
|
+
"import sys,zipfile;z=zipfile.ZipFile(sys.argv[1],'w');z.writestr('inside.txt','hello from inside\\n');z.close()",
|
|
992
|
+
zipPath,
|
|
993
|
+
]);
|
|
994
|
+
if (built.status !== 0) {
|
|
995
|
+
t.skip("python3 is unavailable, so neither the fixture nor the fallback can run");
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const payload = readFileSync(zipPath);
|
|
999
|
+
const python = spawnSync("sh", ["-c", "command -v python3"], { encoding: "utf8" }).stdout.trim();
|
|
1000
|
+
assert.ok(python, "python3 was just used to build the fixture");
|
|
1001
|
+
|
|
1002
|
+
// A PATH that has python3 and nothing else: unzip cannot be found, which is
|
|
1003
|
+
// exactly the bare-server case. Ghidra ships only as a zip, so this used to be
|
|
1004
|
+
// a certain failure rather than an occasional one.
|
|
1005
|
+
const binDir = path.join(root, "bin");
|
|
1006
|
+
mkdirSync(binDir, { recursive: true });
|
|
1007
|
+
const onlyPython = path.join(binDir, "python3");
|
|
1008
|
+
symlinkSync(python, onlyPython);
|
|
1009
|
+
const setup = makePluginTree(root, (manifest) => {
|
|
1010
|
+
manifest.components.androidPlatformTools = testArchive("http://127.0.0.1:9/fixture.zip", payload);
|
|
1011
|
+
});
|
|
1012
|
+
const cases = [
|
|
1013
|
+
{ label: "python3 fallback", path: binDir, expected: 0 },
|
|
1014
|
+
{ label: "no extractor at all", path: path.join(root, "empty-bin"), expected: 1 },
|
|
1015
|
+
];
|
|
1016
|
+
for (const testCase of cases) {
|
|
1017
|
+
mkdirSync(testCase.path, { recursive: true });
|
|
1018
|
+
const bundle = path.join(root, `bundle-${testCase.expected}`);
|
|
1019
|
+
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
|
1020
|
+
writeFileSync(path.join(bundle, "cache", "fixture.zip"), payload);
|
|
1021
|
+
addReaStub(bundle);
|
|
1022
|
+
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
|
|
1023
|
+
encoding: "utf8",
|
|
1024
|
+
timeout: 120000,
|
|
1025
|
+
env: { ...process.env, PATH: testCase.path },
|
|
1026
|
+
});
|
|
1027
|
+
assert.equal(run.status, testCase.expected, `${testCase.label}: ${run.stdout}\n${run.stderr}`);
|
|
1028
|
+
if (testCase.expected === 0) {
|
|
1029
|
+
assert.equal(readFileSync(path.join(bundle, "vendor", "arch", "inside.txt"), "utf8"), "hello from inside\n");
|
|
1030
|
+
} else {
|
|
1031
|
+
assert.match(run.stdout, /no zip extractor available/);
|
|
1032
|
+
assert.match(run.stdout, /install unzip or python3/);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
} finally {
|
|
1036
|
+
rmSync(root, { recursive: true, force: true });
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
test("the fallback vendored-REA path matches where setup installs it", () => {
|
|
1041
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-fallback-"));
|
|
1042
|
+
try {
|
|
1043
|
+
const bundle = path.join(root, "bundle");
|
|
1044
|
+
// An older bundle.json has paths.node but no paths.reaEntry. The fallback used
|
|
1045
|
+
// to omit `node_modules`, so a working install was reported as gone.
|
|
1046
|
+
mkdirSync(bundle, { recursive: true });
|
|
1047
|
+
addReaStub(bundle);
|
|
1048
|
+
writeFileSync(path.join(bundle, "bundle.json"), JSON.stringify({ schema: 1, paths: { node: process.execPath } }, null, 2) + "\n");
|
|
1049
|
+
const run = spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), "--shim-status"], {
|
|
1050
|
+
encoding: "utf8",
|
|
1051
|
+
timeout: 60000,
|
|
1052
|
+
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0" },
|
|
1053
|
+
});
|
|
1054
|
+
const report = JSON.parse(run.stdout);
|
|
1055
|
+
assert.equal(report.rea.ok, true, JSON.stringify(report.rea));
|
|
1056
|
+
assert.match(report.rea.reaEntry, /node_modules/);
|
|
1057
|
+
} finally {
|
|
1058
|
+
rmSync(root, { recursive: true, force: true });
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("--json releases the lock on success and marks it failed on failure", () => {
|
|
1063
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-lock-json-"));
|
|
1064
|
+
try {
|
|
1065
|
+
const bundle = path.join(root, "bundle");
|
|
1066
|
+
mkdirSync(bundle, { recursive: true });
|
|
1067
|
+
addReaStub(bundle);
|
|
1068
|
+
const component = (optional) =>
|
|
1069
|
+
makePluginTree(path.join(root, optional ? "ok" : "bad"), (manifest) => {
|
|
1070
|
+
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional });
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// The lock is what tells the shim a run is in flight. A successful --json run
|
|
1074
|
+
// used to leave it behind (the removal sat in the non---json branch), so a
|
|
1075
|
+
// fully provisioned machine advertised "provisioning started" for six hours.
|
|
1076
|
+
writeFileSync(path.join(bundle, "setup.lock"), JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }));
|
|
1077
|
+
const ok = spawnSync(process.execPath, [component(true), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
|
|
1078
|
+
encoding: "utf8",
|
|
1079
|
+
timeout: 120000,
|
|
1080
|
+
});
|
|
1081
|
+
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
|
|
1082
|
+
assert.equal(existsSync(path.join(bundle, "setup.lock")), false, "a successful --json run must not leave the lock behind");
|
|
1083
|
+
|
|
1084
|
+
// A failing run keeps the lock on purpose, but records why, so the shim can
|
|
1085
|
+
// report a failure instead of pretending a run is still going.
|
|
1086
|
+
const bad = spawnSync(process.execPath, [component(false), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
|
|
1087
|
+
encoding: "utf8",
|
|
1088
|
+
timeout: 120000,
|
|
1089
|
+
});
|
|
1090
|
+
assert.equal(bad.status, 1, `${bad.stdout}\n${bad.stderr}`);
|
|
1091
|
+
const lock = JSON.parse(readFileSync(path.join(bundle, "setup.lock"), "utf8"));
|
|
1092
|
+
assert.equal(lock.state, "failed");
|
|
1093
|
+
assert.equal(lock.exit, 1);
|
|
1094
|
+
} finally {
|
|
1095
|
+
rmSync(root, { recursive: true, force: true });
|
|
1096
|
+
}
|
|
1097
|
+
});
|