SOURCE / PINNED RELEASE
Made of little things.
Powder Tool V600Billion
- Release
- 142767edcab8…
- Author-recorded commit
- 6d92971effd0…
- License
- LICENSE
- Author’s source reference
- nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy
Archive hash verified: ed7d6a8ea7083197…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* The built napplet (dist/index.html) in a real browser, through scripts/harness.mjs, once in
* each player: the Hangar's (the real Kehto prelude) and napplet.soy's (its
* player policy, @napplet/shim and its storage limits).
*
* npm run smoke [-- hangar|soy] (PLAYWRIGHT_CHROMIUM_EXECUTABLE=... for a Chromium of your own)
*
* Checks, in order: the game starts in a plain sandboxed frame (no
* cross-origin isolation, no SharedArrayBuffer) and fills the room between
* the page's bars; a click lands where it is drawn after CSS scaling;
* gravity runs; a file written by the game is kept in host storage and is
* back after the napplet reloads; a file too large for the host is reported
* and does not stop the settings from being kept; the game's links go
* through link.open; the page's palette sets the game's tools; keys typed
* into the page's search box do not reach the game; a scene loads from the
* gallery; the game's dialogs open from the page's buttons and the page shows
* the whole game window while they are open. Screenshots land in qa/.
*/
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { QA_DIR as output, startHarness } from './harness.mjs';
import { litShare, readPng } from '../test/lib/png.mjs';
mkdirSync(output, { recursive: true });
async function canvasBox(harness) {
return harness.frame().evaluate(() => {
const canvas = document.getElementById('canvas');
const rect = canvas.getBoundingClientRect();
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height, cw: canvas.width, ch: canvas.height };
});
}
/** Type a line into the game's Lua console and run it. */
async function lua(harness, line) {
const { tab } = harness;
await tab.keyboard.press('Backquote');
await tab.waitForTimeout(250);
await tab.keyboard.type(line, { delay: 2 });
await tab.keyboard.press('Enter');
await tab.waitForTimeout(250);
await tab.keyboard.press('Backquote');
await tab.waitForTimeout(250);
}
const results = [];
const check = (name, fn) => fn().then(() => results.push(`ok ${name}`), (error) => { results.push(`FAIL ${name}: ${error.message}`); throw error; });
const players = process.argv[2] ? [process.argv[2]] : ['hangar', 'soy'];
for (const player of players) {
results.push(`${player}:`);
const harness = await startHarness({ player });
const shot = (name) => `${output}smoke-${player}-${name}.png`;
try {
await check('starts in a plain sandboxed frame and fills it', async () => {
const ms = await harness.waitForGame();
await harness.frame().waitForFunction(() => window.powderUi, null, { timeout: 10000 });
const box = await canvasBox(harness);
assert.equal(box.cw, 629); assert.equal(box.ch, 424);
// The page shows the simulation as large as the room between its bars allows.
const fit = await harness.frame().evaluate(() => {
const room = document.getElementById('room').getBoundingClientRect();
const view = document.getElementById('viewport');
const shown = view.getBoundingClientRect();
return { rw: room.width, rh: room.height, vw: shown.width, vh: shown.height, shows: view.dataset.shows };
});
assert.equal(fit.shows, 'simulation');
assert.ok(fit.vw >= fit.rw * 0.97 || fit.vh >= fit.rh * 0.97, `the simulation is shown at ${fit.vw}x${fit.vh} in ${fit.rw}x${fit.rh}`);
const inside = await harness.frame().evaluate(() => ({
isolated: self.crossOriginIsolated, sab: typeof SharedArrayBuffer, origin: location.origin,
ready: document.documentElement.dataset.nappletReady,
}));
assert.deepEqual(inside, { isolated: false, sab: 'undefined', origin: 'null', ready: 'true' });
results.push(` presentable after ${ms} ms, drawn at ${Math.round(box.w)}x${Math.round(box.h)}`);
});
await check('a click lands where it is drawn after scaling', async () => {
const box = await canvasBox(harness);
const scale = box.w / box.cw;
await harness.frame().click('#canvas', { position: { x: 5, y: 5 } }); // focus
await harness.tab.keyboard.press('Space'); // pause, so the dust stays where it lands
await harness.tab.waitForTimeout(300);
const target = { x: 150, y: 150 };
await harness.tab.mouse.click(box.x + target.x * scale, box.y + target.y * scale);
await harness.tab.waitForTimeout(400);
const image = readPng(await harness.tab.screenshot({ path: shot('draw') }));
const near = litShare(image, { x: Math.round(box.x + (target.x - 4) * scale), y: Math.round(box.y + (target.y - 4) * scale), w: Math.round(8 * scale), h: Math.round(8 * scale) });
const far = litShare(image, { x: Math.round(box.x + 400 * scale), y: Math.round(box.y + 250 * scale), w: Math.round(30 * scale), h: Math.round(30 * scale) });
assert.ok(near > 0.15, `no particles at the click (lit ${near.toFixed(2)})`);
assert.ok(far < 0.02, `particles away from the click (lit ${far.toFixed(2)})`);
});
await check('files the game writes are kept and come back after a reload', async () => {
await lua(harness, 'f=io.open("/powder/probe.txt","w") f:write("kept") f:close()');
await harness.tab.waitForFunction(() => window.__host.storage.has('fs:/powder/probe.txt'), null, { timeout: 5000 });
const stored = JSON.parse(await harness.host(() => window.__host.storage.get('fs:/powder/probe.txt')));
assert.equal(Buffer.from(stored.data, 'base64').toString(), 'kept');
await harness.host(() => window.__host.reload());
await harness.waitForGame();
await harness.frame().click('#canvas', { position: { x: 5, y: 5 } });
await lua(harness, 'if io.open("/powder/probe.txt") then g=io.open("/powder/seen.txt","w") g:write("yes") g:close() end');
await harness.tab.waitForFunction(() => window.__host.storage.has('fs:/powder/seen.txt'), null, { timeout: 5000 });
const keys = await harness.host(() => [...window.__host.storage.keys()]);
results.push(` storage keys: ${keys.join(', ')}`);
});
await check('gravity runs without a worker thread', async () => {
// A black hole's mass pulls on the cells around it once the gravity pass has run.
await lua(harness, 'sim.clearSim() sim.newtonianGravity(true) sim.partCreate(-1,300,200,elem.DEFAULT_PT_NBHL) tpt.set_pause(0)');
await harness.tab.waitForTimeout(800);
await lua(harness, 'tpt.set_pause(1) local x,y=sim.gravityField(75,40) local f=io.open("/powder/grav.txt","w") f:write(x.." "..y) f:close()');
await harness.tab.waitForFunction(() => window.__host.storage.has('fs:/powder/grav.txt'), null, { timeout: 5000 });
const text = Buffer.from(JSON.parse(await harness.host(() => window.__host.storage.get('fs:/powder/grav.txt'))).data, 'base64').toString();
const [x, y] = text.split(' ').map(Number);
assert.ok(Number.isFinite(y) && y > 0 && Math.abs(x) < Math.abs(y), `the field above the black hole points down to it: ${text}`);
});
await check('a file too large for the host is reported; the rest is still kept', async () => {
const big = player === 'soy' ? 1_200_000 : 7_000_000;
await lua(harness, `local f=io.open("/powder/big.bin","wb") f:write(string.rep("x", ${big})) f:close() local g=io.open("/powder/after.txt","w") g:write("ok") g:close()`);
await harness.tab.waitForFunction(() => window.__host.storage.has('fs:/powder/after.txt'), null, { timeout: 20000 });
assert.equal(await harness.host(() => window.__host.storage.has('fs:/powder/big.bin')), false);
await harness.frame().waitForFunction(() => /did not keep big\.bin/.test(document.getElementById('toasts')?.textContent ?? ''), null, { timeout: 5000 });
await lua(harness, 'os.remove("/powder/big.bin")');
});
await check('the game\'s links go through link.open', async () => {
await lua(harness, 'platform.openLink("https://powdertoy.co.uk/Wiki.html")');
await harness.tab.waitForFunction(() => window.__host.links.length > 0, null, { timeout: 5000 });
assert.deepEqual(await harness.host(() => window.__host.links), ['https://powdertoy.co.uk/Wiki.html']);
});
await check('the page names its license and opens its source through link.open', async () => {
const frame = harness.frame();
const before = (await harness.host(() => window.__host.links)).length;
await frame.click('#more');
await frame.click('#more-menu [data-action="about"]');
assert.match(await frame.textContent('#about'), /GNU General Public License, version 3/);
await frame.click('#about [data-link="https://git.napplet.soy/npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/powder-toy.git"]');
await harness.tab.waitForFunction((n) => window.__host.links.length > n, before, { timeout: 5000 });
assert.equal((await harness.host(() => window.__host.links)).at(-1), 'https://git.napplet.soy/npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/powder-toy.git');
await frame.click('#about [data-close]');
});
await check('the palette lists the game\'s tools and hands them to the mouse buttons', async () => {
const frame = harness.frame();
const count = await frame.evaluate(() => document.querySelectorAll('#tools .tool').length);
assert.ok(count > 10, `${count} tools in the first section`);
await frame.fill('#tool-search', 'water');
await frame.click('#tools .tool[data-id="DEFAULT_PT_WATR"]');
await frame.click('#tools .tool[data-id="DEFAULT_PT_WATR"]', { button: 'right' });
const tools = await frame.evaluate(() => window.powderUi.lua('return ui.activeTool(0) .. " " .. ui.activeTool(1)'));
assert.equal(tools, 'DEFAULT_PT_WATR DEFAULT_PT_WATR');
await frame.fill('#tool-search', '');
await frame.evaluate(() => window.powderUi.lua('ui.activeTool(0, "DEFAULT_PT_DUST") ui.activeTool(1, "DEFAULT_PT_NONE")'));
});
await check('keys typed into the page\'s search box stay out of the game', async () => {
const frame = harness.frame();
await frame.click('#tool-search');
// E would open the game's element search, Space would pause it.
await harness.tab.keyboard.type('e e');
await harness.tab.waitForTimeout(300);
assert.equal(await frame.inputValue('#tool-search'), 'e e');
assert.equal(await frame.evaluate(() => window.powderUi.onTop()), true, 'a game dialog opened');
await frame.fill('#tool-search', '');
});
await check('scenes load from the gallery and run', async () => {
const frame = harness.frame();
const names = await frame.evaluate(() => window.powderUi.scenes.map((scene) => scene.id));
assert.ok(names.includes('reactor') && names.length >= 8, `scenes: ${names.join(', ')}`);
await frame.evaluate(() => window.powderUi.lua('sim.paused(false)'));
await frame.click('#scenes-open');
await frame.click('.scene-card[data-scene="reactor"]');
await harness.tab.waitForTimeout(1500);
const parts = Number(await frame.evaluate(() => window.powderUi.lua('return sim.partCount()')));
assert.ok(parts > 10000, `${parts} particles after loading the reactor`);
assert.equal(await frame.textContent('#scene-name'), 'Nuclear reactor');
await harness.tab.screenshot({ path: shot('scene') });
await frame.click('#clear');
assert.equal(await frame.textContent('#scene-name'), 'Sandbox');
// The reactor's neutron source stopped with it.
await harness.tab.waitForTimeout(1000);
assert.equal(Number(await frame.evaluate(() => window.powderUi.lua('return sim.partCount()'))), 0, 'the scene went on after the simulation was emptied');
});
await check('a scene ends by itself when something else replaces it', async () => {
const frame = harness.frame();
await frame.evaluate(() => window.powderUi.loadScene(window.powderUi.scenes.find((scene) => scene.id === 'fireworks')));
await harness.tab.waitForTimeout(800);
// What opening a save or undoing the scene does to it: the simulation is replaced.
await frame.evaluate(() => window.powderUi.lua('sim.clearSim()'));
await frame.waitForFunction(() => document.getElementById('scene-name').textContent === 'Sandbox', null, { timeout: 5000 });
await harness.tab.waitForTimeout(1500);
const parts = Number(await frame.evaluate(() => window.powderUi.lua('return sim.partCount()')));
assert.ok(parts < 60, `${parts} particles: the fireworks kept going`);
await frame.click('#clear');
});
await check('every scene loads without a Lua error and fills the simulation', async () => {
const frame = harness.frame();
const ids = await frame.evaluate(() => window.powderUi.scenes.map((scene) => scene.id));
const empty = [];
for (const id of ids) {
await frame.evaluate((sceneId) => window.powderUi.loadScene(window.powderUi.scenes.find((scene) => scene.id === sceneId)), id);
await harness.tab.waitForTimeout(400);
if (Number(await frame.evaluate(() => window.powderUi.lua('return sim.partCount()'))) < 100) empty.push(id);
}
const notices = await frame.evaluate(() => document.getElementById('toasts').textContent);
assert.doesNotMatch(notices, /The game said/, notices);
assert.deepEqual(empty, [], 'scenes that drew next to nothing');
await frame.click('#clear');
});
await check('the game\'s dialogs open from the page, which shows the whole window meanwhile', async () => {
const frame = harness.frame();
await frame.click('#settings');
await frame.waitForFunction(() => document.getElementById('viewport').dataset.shows === 'window', null, { timeout: 5000 });
assert.equal(await frame.evaluate(() => document.body.classList.contains('game-dialog')), true);
await harness.tab.screenshot({ path: shot('dialog') });
await harness.tab.keyboard.press('Escape');
await frame.waitForFunction(() => document.getElementById('viewport').dataset.shows === 'simulation', null, { timeout: 5000 });
// The game shows a hovered button's tooltip at the bottom left of the simulation; the
// hidden button the page clicked must not leave one behind.
await harness.tab.waitForTimeout(1200);
const box = await canvasBox(harness);
const scale = box.w / box.cw;
const tip = readPng(await harness.tab.screenshot({ clip: { x: box.x + 2 * scale, y: box.y + 358 * scale, width: 200 * scale, height: 20 * scale } }));
const lit = litShare(tip, { x: 0, y: 0, w: tip.width, h: tip.height });
assert.ok(lit < 0.01, `a tooltip stayed on the simulation (lit ${lit.toFixed(3)})`);
});
assert.deepEqual(harness.errors, [], 'no uncaught errors in the page');
if (player === 'soy') {
assert.deepEqual(await harness.frame().evaluate(() => window.__violations), [], 'no policy violations under napplet.soy\'s player policy');
results.push('ok no policy violations under napplet.soy\'s player policy');
}
} finally {
await harness.tab.screenshot({ path: shot('final') }).catch(() => {});
writeFileSync(`${output}smoke-${player}-console.txt`, harness.consoleLines.join('\n'));
await harness.close();
console.log(results.join('\n'));
results.length = 0;
}
}
