Refresh the homepage and improve export and draft guidance
This commit is contained in:
@@ -4,7 +4,8 @@ import { mkdir, copyFile } from "node:fs/promises";
|
||||
const windows = process.platform === "win32";
|
||||
const command = windows ? "gradlew.bat" : "sh";
|
||||
const targets = process.argv.slice(2);
|
||||
if (!targets.length) targets.push(":plugin:build", ":fabric:build");
|
||||
if (!targets.some((target) => !target.startsWith("-")))
|
||||
targets.unshift(":plugin:build", ":fabric:build");
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, windows ? targets : ["./gradlew", ...targets], {
|
||||
cwd: "minecraft",
|
||||
|
||||
@@ -29,5 +29,5 @@ const configured = Boolean(
|
||||
console.log(
|
||||
configured
|
||||
? "Supabase configuration present. Apply the checked-in migration before starting."
|
||||
: "Supabase is not configured. Local guest sandbox is available; hosted uploads/moderation remain disabled.",
|
||||
: "Supabase is not configured. Fill the URL, publishable key and server secret in .env.local before starting MineGeo.",
|
||||
);
|
||||
|
||||
210
scripts/test-api.ts
Normal file
210
scripts/test-api.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
process.loadEnvFile(".env.local");
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SECRET_KEY!,
|
||||
{ auth: { persistSession: false } },
|
||||
);
|
||||
const account = JSON.parse(await readFile(".data/test-account.json", "utf8"));
|
||||
const origin = "http://127.0.0.1:3100",
|
||||
directory = `.data/api-smoke-${Date.now()}`;
|
||||
await mkdir(directory, { recursive: true });
|
||||
const manifest = JSON.parse(
|
||||
await readFile(
|
||||
".cache/paper-proof/plugins/MineGeo/export/manifest.json",
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
manifest.name = "Private API regression";
|
||||
manifest.serverName = "MineGeo API regression";
|
||||
await writeFile(`${directory}/manifest.json`, JSON.stringify(manifest));
|
||||
await copyFile(
|
||||
".cache/paper-proof/plugins/MineGeo/export/terrain.json.gz",
|
||||
`${directory}/terrain.json.gz`,
|
||||
);
|
||||
const server = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"node_modules/next/dist/bin/next",
|
||||
"start",
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"3100",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
||||
);
|
||||
let serverLog = "";
|
||||
server.stdout.on("data", (data) => (serverLog += data));
|
||||
server.stderr.on("data", (data) => (serverLog += data));
|
||||
const stopped = new Promise<void>((resolve) =>
|
||||
server.on("exit", () => resolve()),
|
||||
);
|
||||
const request = (path: string, body?: unknown, authenticated = true) =>
|
||||
fetch(origin + "/api/v1/" + path, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
...(authenticated ? { authorization: "Bearer " + account.token } : {}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
let datasetId: string | undefined, serverId: string | undefined;
|
||||
try {
|
||||
let ready = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const response = await fetch(origin + "/api/v1/health");
|
||||
if (response.ok) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
assert.ok(ready, "Production server and database must become healthy");
|
||||
for (const path of [
|
||||
"/",
|
||||
"/servers",
|
||||
"/downloads",
|
||||
"/fonts/VT323-Regular.ttf",
|
||||
"/images/example-map.webp",
|
||||
"/tools/plugin/minegeo-plugin-1.21.11.jar",
|
||||
"/tools/mod/minegeo-fabric-1.21.11.jar",
|
||||
]) {
|
||||
const response = await fetch(origin + path);
|
||||
assert.equal(response.status, 200, path);
|
||||
const bytes = await response.arrayBuffer();
|
||||
assert.ok(bytes.byteLength > 0);
|
||||
if (path === "/") {
|
||||
const html = new TextDecoder().decode(bytes);
|
||||
assert.ok(html.includes("blocky world"));
|
||||
assert.ok(html.includes("COMMUNITY MAPS"));
|
||||
}
|
||||
console.log("HTTP 200 " + path);
|
||||
}
|
||||
const child = spawn(
|
||||
"java",
|
||||
[
|
||||
"--class-path",
|
||||
"public/tools/plugin/minegeo-plugin-1.21.11.jar",
|
||||
"tests/java/UploadSmoke.java",
|
||||
origin,
|
||||
directory,
|
||||
".data/test-account.json",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
||||
);
|
||||
let javaLog = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
javaLog += data;
|
||||
process.stdout.write(data);
|
||||
});
|
||||
child.stderr.on("data", (data) => (javaLog += data));
|
||||
const code = await new Promise<number | null>((resolve) =>
|
||||
child.on("exit", resolve),
|
||||
);
|
||||
assert.equal(code, 0, javaLog);
|
||||
const ledger = JSON.parse(
|
||||
await readFile(`${directory}/.upload-state.json`, "utf8"),
|
||||
);
|
||||
datasetId = ledger.datasetId;
|
||||
const draftResponse = await request("datasets/" + datasetId);
|
||||
assert.equal(draftResponse.status, 200);
|
||||
const draft = await draftResponse.json();
|
||||
serverId = draft.server_id;
|
||||
assert.ok(["processing", "draft"].includes(draft.state));
|
||||
if (draft.state === "processing")
|
||||
assert.ok(
|
||||
draft.previews.every((p: any) => p.image === null),
|
||||
"Pending media must not be signed",
|
||||
);
|
||||
const anonymous = await request("datasets/" + datasetId, undefined, false);
|
||||
assert.ok(anonymous.status >= 400, "Private draft must reject guests");
|
||||
if (draft.state === "processing") {
|
||||
const cancel = await request("datasets/" + datasetId + "/cancel", {});
|
||||
assert.equal(cancel.status, 200, await cancel.text());
|
||||
}
|
||||
// A never-finalized upload has no queue row: cancellation must still work.
|
||||
const created = await request("uploads", { manifest });
|
||||
assert.equal(created.status, 200);
|
||||
const empty = await created.json();
|
||||
const cancelled = await request(
|
||||
"datasets/" + empty.datasetId + "/cancel",
|
||||
{},
|
||||
);
|
||||
assert.equal(cancelled.status, 200, await cancelled.text());
|
||||
const cleanup = await db
|
||||
.from("datasets")
|
||||
.delete()
|
||||
.eq("id", empty.datasetId)
|
||||
.eq("owner_id", account.id);
|
||||
if (cleanup.error) throw cleanup.error;
|
||||
await writeFile(
|
||||
"artifacts-api-smoke.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: true,
|
||||
checks: [
|
||||
"production homepage/catalog/downloads/assets",
|
||||
"Java uploader to real Supabase Storage",
|
||||
"private draft before rendered assets exist",
|
||||
"guests denied private drafts",
|
||||
"cancel queued processing",
|
||||
"cancel unfinished upload without a queue row",
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log("REAL STORAGE / API SMOKE PASSED");
|
||||
} finally {
|
||||
if (!datasetId) {
|
||||
try {
|
||||
datasetId = JSON.parse(
|
||||
await readFile(`${directory}/.upload-state.json`, "utf8"),
|
||||
).datasetId;
|
||||
} catch {}
|
||||
}
|
||||
if (datasetId) {
|
||||
const record = await db
|
||||
.from("datasets")
|
||||
.select("server_id")
|
||||
.eq("id", datasetId)
|
||||
.eq("owner_id", account.id)
|
||||
.maybeSingle();
|
||||
serverId = record.data?.server_id ?? serverId;
|
||||
await db.storage
|
||||
.from("minegeo-raw")
|
||||
.remove(
|
||||
manifest.assets.map((a: any) => `${account.id}/${datasetId}/${a.path}`),
|
||||
);
|
||||
const deleted = await db
|
||||
.from("datasets")
|
||||
.delete()
|
||||
.eq("id", datasetId)
|
||||
.eq("owner_id", account.id);
|
||||
if (deleted.error)
|
||||
console.error("Test dataset cleanup: " + deleted.error.message);
|
||||
}
|
||||
if (serverId) {
|
||||
const remaining = await db
|
||||
.from("datasets")
|
||||
.select("id")
|
||||
.eq("server_id", serverId)
|
||||
.limit(1);
|
||||
if (remaining.data?.length === 0)
|
||||
await db
|
||||
.from("catalog_servers")
|
||||
.delete()
|
||||
.eq("id", serverId)
|
||||
.eq("name", "MineGeo API regression");
|
||||
}
|
||||
server.kill();
|
||||
await stopped;
|
||||
await writeFile(`${directory}/server.log`, serverLog);
|
||||
}
|
||||
232
scripts/test-plugin.ts
Normal file
232
scripts/test-plugin.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } 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 { 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\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;
|
||||
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++;
|
||||
manifest = JSON.parse(bytes.toString()).manifest;
|
||||
json({ datasetId: "fixture" });
|
||||
} 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: 'fixture-token'\nserver-name: 'Regression world'\nchunks-per-tick: 2\nviewpoints: 5\nmax-area-size: 2048\n`,
|
||||
);
|
||||
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;
|
||||
};
|
||||
try {
|
||||
await wait(/Done \(/, 0, 180000);
|
||||
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:/);
|
||||
const relative = (
|
||||
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
|
||||
).trim();
|
||||
const exportDir = `${directory}/plugins/MineGeo/${relative}`;
|
||||
const local = datasetSchema.parse(
|
||||
JSON.parse(await readFile(`${exportDir}/manifest.json`, "utf8")),
|
||||
);
|
||||
assert.equal(local.viewpoints.length, 5);
|
||||
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 command("minegeo upload", /Upload failed: Upload paused/);
|
||||
await command("minegeo upload", /Upload complete\. Open/);
|
||||
assert.equal(creates, 1);
|
||||
assert.equal(finalizes, 1);
|
||||
assert.equal(heads, 1);
|
||||
await command("minegeo upload", /Upload complete\. Open/);
|
||||
assert.equal(finalizes, 1);
|
||||
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: [
|
||||
"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",
|
||||
],
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user