Back to Powder Tool V600Billion
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.

source/scripts/smoke.mjs
/**
 * 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;
}
}