roblox-ts-template/test/bedrock-local-state.test.mjs

194 lines
8.0 KiB
JavaScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import test from "node:test";
import { fileURLToPath, pathToFileURL, URL } from "node:url";
import { buildStateBackend, loadProjectAsync } from "@bedrock-rbx/core";
import { createLocalStatePorts } from "../scripts/bedrock-local-state.mjs";
const pluginUrl = new URL("../scripts/bedrock-local-state.mjs", import.meta.url);
const cli = fileURLToPath(
new URL("../node_modules/@bedrock-rbx/core/dist/cli/run.mjs", import.meta.url),
);
const example = JSON.parse(
readFileSync(new URL("../bedrock.config.example.json", import.meta.url)),
);
const state = (environment = "production") => ({ version: 1, environment, resources: [] });
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), "bedrock-local-state-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const directory = join(root, "state");
return { root, directory, ...createLocalStatePorts(directory) };
}
test("first reads are empty without creating state directories", async (t) => {
const { statePort, stateLockPort, directory } = fixture(t);
assert.deepEqual(await statePort.read("production"), { success: true, data: {} });
assert.deepEqual(await stateLockPort.inspect("production"), { success: true, data: undefined });
assert.equal(existsSync(directory), false);
});
test("state persists across adapter instances with Bedrock bookkeeping and environment isolation", async (t) => {
const { directory, statePort } = fixture(t);
const snapshot = { ...state(), pendingRebuild: new Set(["start"]), codegenHash: "a".repeat(64) };
assert.equal((await statePort.write(snapshot)).success, true);
const fresh = createLocalStatePorts(directory).statePort;
assert.deepEqual((await fresh.read("production")).data.state, snapshot);
assert.equal((await fresh.write(state("staging"))).success, true);
assert.deepEqual((await fresh.read("production")).data.state, snapshot);
assert.deepEqual((await fresh.read("staging")).data.state, state("staging"));
assert.deepEqual(JSON.parse(readFileSync(join(directory, "production.json"))).$bedrock, {
version: 1,
pendingRebuild: ["start"],
codegenHash: "a".repeat(64),
});
assert.deepEqual(readdirSync(directory).sort(), ["production.json", "staging.json"]);
});
test("atomic replacement leaves valid state and no temporary files", async (t) => {
const { directory, statePort } = fixture(t);
await statePort.write(state());
for (let index = 0; index < 5; index++) {
assert.equal(
(await statePort.write({ ...state(), codegenHash: String(index).repeat(64) })).success,
true,
);
assert.equal(
(await statePort.read("production")).data.state.codegenHash,
String(index).repeat(64),
);
}
assert.deepEqual(readdirSync(directory), ["production.json"]);
});
test("corrupt, unsupported and mismatched state never becomes a first deployment", async (t) => {
const { statePort, directory } = fixture(t);
await statePort.write(state());
const file = join(directory, "production.json");
for (const raw of [
"{broken",
JSON.stringify({ $bedrock: { version: 999 }, environment: "production", resources: [] }),
JSON.stringify({ $bedrock: { version: 1 }, environment: "staging", resources: [] }),
]) {
writeFileSync(file, raw);
const result = await statePort.read("production");
assert.equal(result.success, false);
assert.equal(result.err.kind, "stateError");
assert.equal(readFileSync(file, "utf8"), raw);
}
});
test("invalid state cannot replace the last valid snapshot", async (t) => {
const { statePort, directory } = fixture(t);
await statePort.write(state());
const before = readFileSync(join(directory, "production.json"), "utf8");
assert.equal((await statePort.write({ ...state(), version: 999 })).success, false);
assert.equal(readFileSync(join(directory, "production.json"), "utf8"), before);
});
test("unsafe environment names cannot escape the state directory", async (t) => {
const { statePort, stateLockPort, directory } = fixture(t);
for (const environment of ["../outside", "..", "a/b", "a\\b", "CON", ""]) {
assert.equal((await statePort.read(environment)).success, false);
assert.equal((await statePort.write(state(environment))).success, false);
assert.equal((await stateLockPort.acquire(environment)).success, false);
}
assert.equal(existsSync(directory), false);
});
test("deployment locks exclude independent Node processes and release normally", async (t) => {
const { directory, stateLockPort } = fixture(t);
const first = await stateLockPort.acquire("production", { operation: "deploy" });
assert.equal(first.success, true);
assert.equal((await stateLockPort.inspect("production")).data.operation, "deploy");
const probe = () =>
spawnSync(
process.execPath,
[
"--input-type=module",
"-e",
`
import { createLocalStatePorts } from ${JSON.stringify(pluginUrl.href)};
const lock = await createLocalStatePorts(${JSON.stringify(directory)}).stateLockPort.acquire("production");
if (!lock.success) process.exit(7);
await lock.data.release();
`,
],
{ encoding: "utf8" },
);
assert.equal(probe().status, 7);
assert.equal((await first.data.release()).success, true);
const next = probe();
assert.equal(next.status, 0, next.stderr);
assert.equal((await stateLockPort.inspect("production")).data, undefined);
});
test("explicit unlock recovers an abandoned lock without allowing its old holder to release a new one", async (t) => {
const { directory, stateLockPort } = fixture(t);
const old = await stateLockPort.acquire("production");
const fresh = createLocalStatePorts(directory).stateLockPort;
assert.equal((await fresh.forceRelease("production")).success, true);
const next = await fresh.acquire("production");
assert.equal(next.success, true);
assert.equal((await old.data.release()).success, false);
assert.ok((await fresh.inspect("production")).data);
assert.equal((await next.data.release()).success, true);
});
test("the real Bedrock loader and CLI diff work without GitHub credentials or network calls", async (t) => {
const { root, directory } = fixture(t);
const config = globalThis.structuredClone(example);
config.plugins = [pluginUrl.href];
config.environments.production.universe.universeId = "123456789";
config.environments.production.places.start.placeId = "987654321";
config.places.start.filePath = join(root, "game.rbxl");
config.state.directory = directory;
const configFile = join(root, "bedrock.config.json");
writeFileSync(configFile, JSON.stringify(config));
writeFileSync(config.places.start.filePath, "local test artifact");
const loaded = await loadProjectAsync({ cwd: root });
assert.equal(loaded.success, true, JSON.stringify(loaded));
const built = buildStateBackend({
stateConfig: loaded.data.config.state,
plugins: loaded.data.plugins,
getEnv: () => undefined,
});
assert.equal(built.success, true, JSON.stringify(built));
assert.equal(built.data.locking, "exclusive");
await built.data.statePort.write(state());
assert.deepEqual((await built.data.statePort.read("production")).data.state, state());
rmSync(directory, { recursive: true });
const guard = join(root, "offline.mjs");
writeFileSync(
guard,
'globalThis.fetch = () => { throw new Error("Unexpected network request"); };',
);
const env = { ...process.env };
delete env.BEDROCK_GITHUB_TOKEN;
delete env.BEDROCK_API_KEY;
const result = spawnSync(
process.execPath,
[
"--import",
pathToFileURL(guard).href,
cli,
"diff",
"--config",
configFile,
"--env",
"production",
],
{
cwd: root,
env,
encoding: "utf8",
},
);
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.equal(existsSync(directory), false);
});