Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/modkit/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ Tests that write the same world (for example player position) must run in order.
| ------------------------------------ | --------------------------------------------------------------------------------------------- |
| `game.evaluate(fn, ...args)` | Run `fn` in the renderer. Arguments must be JSON values. Closures do not capture Node locals. |
| `game.waitFor(read, match, options)` | Poll `read` in the page until `match` is true in Node. |
| `game.buildStructures(placements)` | Build several structures in one renderer turn and wait for every anchor. |
| `game.buildLayout(layout)` | Expand a visual fixture diagram into phased structure placements. |
| `game.setSimulationPaused(paused)` | Pause or resume the simulation without opening the in-game pause menu. |
| `game.pauseSimulation()` | Pause the simulation. |
| `game.resumeSimulation()` | Resume the simulation. |
| `game.runSimulation(durationMs)` | Run live simulation for a wall-clock duration, then restore the prior pause state. |
| `game.orderedModIds()` | Return live `manifest.id` values from the ordered mod list. |
| `game.screenshot(options)` | Capture a PNG of the compositor (WebGL plus DOM). Returns a `Buffer`. |
| `game.withModMain(id, fn)` | Edit the test-host `main.js`, then restore the original bytes. |
Expand All @@ -104,6 +110,68 @@ Tests that write the same world (for example player position) must run in order.

Return values from `evaluate` must be JSON-serializable.

### Structure fixtures

`game.buildStructures(placements)` builds a batch of structures and waits for
their anchors to appear. Each placement uses cell coordinates and can include
`options` or seeded `data`. Seeded data is applied after the structure anchor
exists, which makes it reliable for custom structure initialization.

The helper resumes the simulation while building, then restores its previous
pause state. An empty placement list is a no-op.

For fixtures that are easier to understand as a diagram, use
`game.buildLayout()`. Each character represents one structure on a 4-cell
grid; `.` leaves a cell empty:

```ts
await game.buildLayout({
origin: { x: 2400, y: 1612 },
cells: ["fff", "fsf", "fff"],
legend: {
f: { type: "foundation" },
s: { type: "mySource", data: { mode: "sand" } },
},
});
```

Use `phases` when placement order matters. Every phase is expanded and built
before the next phase begins:

```ts
await game.buildLayout({
origin: { x: 2400, y: 1612 },
phases: [
{
cells: ["fff", "f.f", "fff"],
legend: { f: { type: "foundation" } },
},
{
cells: ["...", ".s.", "..."],
legend: { s: { type: "mySource" } },
},
],
});
```

The top-left character is placed at `origin`; columns and rows add four cells
per step. Use either top-level `cells` and `legend`, or `phases`, but not both.

### Simulation control

The integration host starts with the simulation paused. Use
`runSimulation()` for a bounded behavior check; it resumes the simulation for
the requested wall-clock duration and restores the state afterward:

```ts
await game.runSimulation(1000);
```

For longer workflows, use `resumeSimulation()` and `pauseSimulation()`
explicitly. `setSimulationPaused(value)` is useful when a test needs to
restore or assert a specific state. These helpers change the engine session
state directly and do not open the game’s pause menu.

## Screenshots

`expect(game).toHaveScreenshot(name)` captures, then compares against a PNG next to the test file. `expect(png).toMatchSnapshot(name)` compares a buffer you already captured. Value checks stay on `node:assert`.
Expand Down
2 changes: 1 addition & 1 deletion modkit/test/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function hostWindowMode(input?: {
const visible = input?.visible === true;
const platform = input?.platform ?? process.platform;
const display = input && "display" in input ? input.display : process.env.DISPLAY;
if (visible && (platform === "win32" || display)) return "window";
if (visible && (platform === "win32" || platform === "darwin" || display)) return "window";
return "headless";
}

Expand Down
4 changes: 2 additions & 2 deletions modkit/test/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ test("hostWindowMode is headless when not visible", () => {
assert.equal(hostWindowMode({ platform: "darwin", display: undefined }), "headless");
});

test("hostWindowMode uses a window only when visible and a display exists", () => {
test("hostWindowMode uses a window when visible on desktop platforms", () => {
assert.equal(hostWindowMode({ visible: true, platform: "win32" }), "window");
assert.equal(hostWindowMode({ visible: true, platform: "darwin" }), "window");
assert.equal(hostWindowMode({ visible: true, platform: "linux", display: ":1" }), "window");
});

Expand Down Expand Up @@ -49,7 +50,6 @@ test("chromeLaunchArgs pins ozone screen size in headless", () => {
assert.ok(!window.some((arg) => arg.startsWith("--ozone-override-screen-size=")));
});


test("resolveHostStaticFile prefers live /mods/<id>/ then vanilla dist/mods", () => {
const root = mkdtempSync(join(tmpdir(), "sandustry-host-"));
const distMods = join(root, "dist", "mods");
Expand Down
2 changes: 1 addition & 1 deletion modkit/test/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ export async function startSandustryTestHost(options?: {
if (!extractedDistDir()) {
return { ok: false, reason: "No sandustry/<version>-<branch>/dist. Run npm run setup." };
}
if (visible && process.platform !== "win32" && !process.env.DISPLAY) {
if (visible && process.platform === "linux" && !process.env.DISPLAY) {
return { ok: false, reason: "DISPLAY is missing" };
}

Expand Down
13 changes: 13 additions & 0 deletions modkit/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export {
} from "./paths.ts";
export { setupGame } from "./setup-game.ts";
export { SandustrySession } from "./session.ts";
export {
buildLayout,
buildStructures,
pauseSimulation,
resumeSimulation,
runSimulation,
setSimulationPaused,
} from "../../test/helpers/world.ts";
export { expect } from "./helpers/expect.ts";
export { toPageExpression } from "./serialize.ts";
export { waitFor } from "./helpers/wait.ts";
Expand All @@ -28,6 +36,11 @@ export type {
ScreenshotClip,
ScreenshotOptions,
SessionWaitForOptions,
StructureLayout,
ElementSeed,
StructureLayoutPhase,
StructureLayoutSymbol,
StructurePlacement,
} from "./session.ts";
export type { BufferExpect, SessionExpect, ToHaveScreenshotOptions } from "./helpers/expect.ts";
export type { ImageMatchOptions } from "./helpers/screenshot.ts";
Expand Down
47 changes: 47 additions & 0 deletions modkit/test/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ import {
} from "./readiness.ts";
import { toPageExpression } from "./serialize.ts";
import { waitFor, type WaitForOptions } from "./helpers/wait.ts";
import {
buildLayout,
buildStructures,
runSimulation,
setSimulationPaused,
type ElementSeed,
type StructureLayout,
type StructureLayoutPhase,
type StructureLayoutSymbol,
type StructurePlacement,
} from "../../test/helpers/world.ts";

export type ModMainFile = {
path: string;
Expand All @@ -25,6 +36,14 @@ export type SessionWaitForOptions<TArgs extends unknown[] = unknown[]> = WaitFor
args?: TArgs;
};

export type {
ElementSeed,
StructureLayout,
StructureLayoutPhase,
StructureLayoutSymbol,
StructurePlacement,
} from "../../test/helpers/world.ts";

export type { ScreenshotClip };

export type ScreenshotOptions = {
Expand Down Expand Up @@ -105,6 +124,34 @@ export class SandustrySession {
return waitFor(() => this.evaluate(read, ...pageArgs), match, options);
}

/** Build several structures in one renderer turn and wait for their anchors. */
async buildStructures(placements: readonly StructurePlacement[]): Promise<void> {
await buildStructures(this, placements);
}

/** Build a readable 4-cell-grid fixture, optionally in explicit phases. */
async buildLayout(layout: StructureLayout): Promise<void> {
await buildLayout(this, layout);
}

/** Pause or resume the simulation without opening the in-game pause UI. */
async setSimulationPaused(paused: boolean): Promise<void> {
await setSimulationPaused(this, paused);
}

async pauseSimulation(): Promise<void> {
await this.setSimulationPaused(true);
}

async resumeSimulation(): Promise<void> {
await this.setSimulationPaused(false);
}

/** Run the simulation for a wall-clock interval, then restore its prior state. */
async runSimulation(durationMs: number): Promise<void> {
await runSimulation(this, durationMs);
}

/** Return `manifest.id` values from the live ordered mod list. */
async orderedModIds(): Promise<string[]> {
return this.evaluate(() => {
Expand Down
Loading