Files
MineGeo/scripts/test-plugin.ts

399 lines
14 KiB
TypeScript

import { createServer } from "node:http";
import { connect } from "node:net";
import { spawn, execFile } from "node:child_process";
import { mkdir, copyFile, writeFile, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { gunzipSync } from "node:zlib";
import assert from "node:assert/strict";
import path from "node:path";
import { promisify } from "node:util";
import { datasetSchema } from "../shared/protocol";
// Disposable, loopback-only Paper test. No game client is launched.
const directory = `.cache/plugin-regression-${Date.now()}`;
await mkdir(`${directory}/plugins/MineGeo`, { recursive: true });
await copyFile(".cache/paper-proof/paper.jar", `${directory}/paper.jar`);
await copyFile(
"public/tools/plugin/minegeo-plugin-1.21.11.jar",
`${directory}/plugins/minegeo.jar`,
);
await writeFile(`${directory}/eula.txt`, "eula=true\n");
await writeFile(
`${directory}/server.properties`,
'server-ip=127.0.0.1\nserver-port=25580\nenable-rcon=true\nrcon.port=25581\nrcon.password=minegeo-fixture\nonline-mode=true\nlevel-type=minecraft:flat\ngenerator-settings={"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:dirt","height":2},{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}\ngenerate-structures=false\nview-distance=2\nsimulation-distance=2\nspawn-protection=0\nmax-players=1\n',
);
let manifest: any,
received = Buffer.alloc(0),
stage = "uploading",
interrupted = false,
creates = 0,
finalizes = 0,
heads = 0;
let expectedServer: string | null = "play.regression.example.com";
const api = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk);
const bytes = Buffer.concat(chunks);
const json = (data: unknown, status = 200) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
};
const origin = `http://127.0.0.1:${(api.address() as any).port}`;
if (req.url === "/api/v1/uploads") {
creates++;
stage = "uploading";
manifest = JSON.parse(bytes.toString()).manifest;
json({ datasetId: "fixture" });
} else if (
req.url === "/api/v1/contributions/00000000-0000-4000-8000-000000000024"
)
json({
remaining: 19,
playerRemaining: 12,
serverAddress: expectedServer,
bounds: { minX: -32, minZ: -32, maxX: 32, maxZ: 32 },
});
else if (
req.url ===
"/api/v1/contributions/00000000-0000-4000-8000-000000000024/server"
)
json({ serverAddress: expectedServer });
else if (req.url === "/api/v1/datasets/fixture") json({ state: stage });
else if (req.url === "/api/v1/uploads/sign")
json({
assets: manifest.assets.map((a: any) => ({
...a,
endpoint: origin + "/tus",
token: "fixture-signature",
objectName: a.path,
})),
});
else if (req.url === "/tus") {
res.writeHead(201, { Location: origin + "/tus/file" });
res.end();
} else if (req.url === "/tus/file" && req.method === "HEAD") {
heads++;
res.writeHead(200, { "Upload-Offset": String(received.length) });
res.end();
} else if (req.url === "/tus/file" && req.method === "PATCH") {
assert.equal(Number(req.headers["upload-offset"]), received.length);
if (!interrupted) {
interrupted = true;
received = Buffer.concat([
received,
bytes.subarray(0, Math.floor(bytes.length / 2)),
]);
res.writeHead(503);
res.end("Simulated interruption");
} else {
received = Buffer.concat([received, bytes]);
res.writeHead(204, { "Upload-Offset": String(received.length) });
res.end();
}
} else if (req.url === "/api/v1/uploads/finalize") {
finalizes++;
assert.equal(
createHash("sha256").update(received).digest("hex"),
manifest.assets[0].sha256,
);
stage = "processing";
json({ state: stage });
} else
json({ error: "Unexpected request " + req.method + " " + req.url }, 404);
});
await new Promise<void>((r) => api.listen(0, "127.0.0.1", r));
await writeFile(
`${directory}/plugins/MineGeo/config.yml`,
`website: 'http://127.0.0.1:${(api.address() as any).port}'\ntoken: ''\nserver-name: 'Regression world'\nserver-address: 'play.regression.example.com'\nchunks-per-tick: 2\nviewpoints: 24\nmax-area-size: 2048\n`,
);
// Exercise the shared upload transport against the loopback mock separately.
// The deployed plugin always uses its hardcoded production endpoint.
const execute = promisify(execFile);
let exportDir: string;
const pluginJar = path.resolve("public/tools/plugin/minegeo-plugin-0.1.3.jar");
await execute("javac", [
"-cp",
pluginJar,
"-d",
directory,
"tests/java/UploadSmoke.java",
]);
await writeFile(
directory + "/fixture-auth.json",
JSON.stringify({ token: "fixture-token" }),
);
const upload = async (
target: string | null,
expected: RegExp,
fails = false,
) => {
const args = [
"-cp",
directory + path.delimiter + pluginJar,
"UploadSmoke",
"http://127.0.0.1:" + (api.address() as any).port,
exportDir,
directory + "/fixture-auth.json",
];
if (target) args.push(target);
let output = "",
code = 0;
try {
const result = await execute("java", args, { windowsHide: true });
output = result.stdout + result.stderr;
} catch (error: any) {
output = error.stdout + error.stderr;
code = error.code;
}
assert.equal(code === 0, !fails, output);
assert.match(output, expected);
};
const child = spawn("java", ["-Xmx1536M", "-jar", "paper.jar", "--nogui"], {
cwd: directory,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
let output = "",
waiters: {
pattern: RegExp;
resolve: () => void;
reject: (e: Error) => void;
start: number;
timer: ReturnType<typeof setTimeout>;
}[] = [];
const log = (bytes: Buffer) => {
const text = bytes.toString();
output += text;
for (const line of text.split("\n"))
if (/MineGeo|Done \(|ERROR/.test(line)) process.stdout.write(line + "\n");
for (const waiter of [...waiters])
if (waiter.pattern.test(output.slice(waiter.start))) {
clearTimeout(waiter.timer);
waiters = waiters.filter((w) => w !== waiter);
waiter.resolve();
}
};
child.stdout.on("data", log);
child.stderr.on("data", log);
const exited = new Promise<void>((resolve) =>
child.on("exit", () => {
for (const w of waiters) {
clearTimeout(w.timer);
w.reject(new Error("Paper stopped before " + w.pattern));
}
waiters = [];
resolve();
}),
);
const wait = (pattern: RegExp, start = output.length, timeout = 90000) =>
new Promise<void>((resolve, reject) => {
const w = {
pattern,
start,
resolve,
reject,
timer: setTimeout(() => {
waiters = waiters.filter((v) => v !== w);
reject(new Error("Timed out waiting for " + pattern));
}, timeout),
};
waiters.push(w);
if (pattern.test(output.slice(start))) {
clearTimeout(w.timer);
waiters = waiters.filter((v) => v !== w);
resolve();
}
});
const command = async (text: string, pattern: RegExp) => {
const pending = wait(pattern);
child.stdin.write(text + "\n");
await pending;
};
const rconCommand = (text: string, expected: RegExp) =>
new Promise<void>((resolve, reject) => {
const socket = connect(25581, "127.0.0.1");
let buffer = Buffer.alloc(0);
let authenticated = false;
const timeout = setTimeout(
() => finish(new Error(`RCON timed out waiting for ${expected}`)),
10000,
);
const finish = (error?: Error) => {
clearTimeout(timeout);
socket.destroy();
if (error) reject(error);
else resolve();
};
const packet = (id: number, type: number, body: string) => {
const payload = Buffer.from(body);
const frame = Buffer.alloc(payload.length + 14);
frame.writeInt32LE(payload.length + 10, 0);
frame.writeInt32LE(id, 4);
frame.writeInt32LE(type, 8);
payload.copy(frame, 12);
return frame;
};
socket.on("connect", () => socket.write(packet(1, 3, "minegeo-fixture")));
socket.on("error", finish);
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 4 && buffer.length >= buffer.readInt32LE(0) + 4) {
const length = buffer.readInt32LE(0);
const frame = buffer.subarray(0, length + 4);
buffer = buffer.subarray(length + 4);
const id = frame.readInt32LE(4);
const type = frame.readInt32LE(8);
if (id === -1) return finish(new Error("RCON authentication failed"));
if (id === 1 && type === 2 && !authenticated) {
authenticated = true;
socket.write(packet(2, 2, text));
} else if (
id === 2 &&
expected.test(frame.toString("utf8", 12, length + 2))
) {
return finish();
}
}
});
});
try {
await wait(/Done \(/, 0, 180000);
await command("minegeo help", /MineGeo commands:/);
await command("minegeo status", /MineGeo: Idle/);
await command("minegeo unknown", /Unknown command\. Use \/minegeo help/);
await rconCommand("minegeo help", /MineGeo commands:/);
await command("minegeo upload", /No completed export/);
await command(
"minegeo area world 100000 100000 100032 100032",
/area selected/,
);
await command(
"minegeo export Empty Region",
/Export failed: No generated chunks/,
);
await command("minegeo area world -32 -32 32 32", /area selected/);
child.stdin.write("forceload add -32 -32 31 31\n");
await new Promise((r) => setTimeout(r, 5000));
await command("minegeo export Regression World", /Export complete:/);
let relative = (
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
).trim();
exportDir = `${directory}/plugins/MineGeo/${relative}`;
const local = datasetSchema.parse(
JSON.parse(await readFile(`${exportDir}/manifest.json`, "utf8")),
);
assert.equal(local.viewpoints.length, 24);
assert.equal(local.serverAddress, "play.regression.example.com");
const terrain = JSON.parse(
gunzipSync(await readFile(`${exportDir}/terrain.json.gz`)).toString(),
);
assert.ok(terrain.blocks.length > 0);
assert.ok(terrain.coverage.length > 0);
assert.ok(
local.viewpoints.every(
(p) => p.x >= -32 && p.x < 32 && p.z >= -32 && p.z < 32,
),
);
await upload(null, /Upload paused/, true);
await upload(null, /queueing panorama processing|Already uploaded/);
assert.equal(creates, 1);
assert.equal(finalizes, 1);
assert.equal(heads, 1);
await upload(null, /queueing panorama processing|Already uploaded/);
assert.equal(finalizes, 1);
received = Buffer.alloc(0);
expectedServer = "wrong.example.com";
await upload(
"00000000-0000-4000-8000-000000000024",
/Wrong capture server/,
true,
);
assert.equal(creates, 1, "Mismatch must stop before creating a draft");
expectedServer = "PLAY.REGRESSION.EXAMPLE.COM.:25565";
await upload(
"00000000-0000-4000-8000-000000000024",
/queueing panorama processing|Already uploaded/,
);
assert.equal(manifest.contributionTo, "00000000-0000-4000-8000-000000000024");
assert.equal(manifest.viewpoints.length, 12);
assert.equal(creates, 2);
expectedServer = "wrong.example.com";
await upload(
"00000000-0000-4000-8000-000000000024",
/Wrong capture server/,
true,
);
assert.equal(creates, 2, "A cached selection must recheck the server");
expectedServer = null;
await upload(
"00000000-0000-4000-8000-000000000024",
/queueing panorama processing|Already uploaded/,
);
assert.equal(creates, 2, "Cleared server permits an idempotent retry");
assert.equal(finalizes, 2);
await upload(
"00000000-0000-4000-8000-000000000024",
/queueing panorama processing|Already uploaded/,
);
assert.equal(creates, 2);
await command("minegeo export Random Positions", /Export complete:/);
relative = (
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
).trim();
const repeated = JSON.parse(
await readFile(
`${directory}/plugins/MineGeo/${relative}/manifest.json`,
"utf8",
),
);
assert.equal(repeated.viewpoints.length, 24);
assert.notDeepEqual(repeated.viewpoints, local.viewpoints);
await command("minegeo area world -256 -256 256 256", /area selected/);
const cancelled = wait(/Cancelled\. Completed exports are kept/);
child.stdin.write("minegeo export Cancel Test\nminegeo cancel\n");
await cancelled;
assert.equal(
(
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
).trim(),
relative,
);
await writeFile(
"artifacts-plugin-regression.json",
JSON.stringify(
{
passed: true,
checks: [
"local console help, status and errors produce visible output",
"remote console help produces visible output",
"missing export guidance",
"empty area failure",
"real Paper export without a player",
"shared schema validation",
"TUS interruption and byte-perfect resume",
"repeated upload is idempotent",
"cancellation preserves completed export",
"24 random positions change on the next export",
"shared upload client caps contributions at 12 and retries idempotently",
"wrong server blocked before upload, including cached retry",
"owner override/default port and cleared address supported",
],
blocks: terrain.blocks.length,
chunks: terrain.coverage.length,
viewpoints: local.viewpoints.length,
directory,
},
null,
2,
),
);
console.log("PLUGIN REGRESSION PASSED");
} finally {
child.stdin.write("stop\n");
const kill = setTimeout(() => child.kill(), 15000);
await exited;
clearTimeout(kill);
await new Promise<void>((r) => api.close(() => r()));
await writeFile(`${directory}/regression.log`, output);
}