app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
#!/usr/bin/env node
// Desktop / Windows portability (hero-studio#60-#66): node test/platform.mjs
//
// The studio assumed an Adom container: a token at /var/run/adom/api-key,
// HOME set, python3 with Pillow, Linux Chrome paths, a VS Code proxy URL.
// These checks pin the replacements, on this Linux box, by driving the
// platform branches with explicit inputs (and a real server with a desktop
// identity and no stamp tools).
import { spawn, execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import net from 'net';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const RP = require(path.join(ROOT, 'renderer-paths.cjs'));
let pass = 0; const fails = [];
const ok = (name, cond, detail) => { if (cond) { pass++; console.log(' ok ' + name); } else { fails.push(name); console.log(' FAIL ' + name + (detail ? ': ' + detail : '')); } };
// --- identity order (#60), each source alone, in a throwaway home ---
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-plat-'));
const home = path.join(tmp, 'home'); fs.mkdirSync(home, { recursive: true });
const cfg = path.join(tmp, 'cfg');
const baseEnv = { PATH: process.env.PATH, HOME: home, XDG_CONFIG_HOME: cfg, HERO_STUDIO_CONTAINER_KEY: path.join(tmp, 'no-key') };
const ident = (extra = {}) => JSON.parse(execFileSync(process.execPath, ['-e',
"const p=require(" + JSON.stringify(path.join(ROOT, 'platform.cjs')) + ");const i=p.wikiIdentity();console.log(JSON.stringify({source:i.source,anonymous:i.anonymous,token:i.token}))"],
{ env: { ...baseEnv, ...extra } }).toString());
let id = ident();
ok('no identity anywhere reads as anonymous', id.anonymous && id.source === 'none', JSON.stringify(id));
fs.mkdirSync(path.join(home, '.adom'), { recursive: true });
fs.writeFileSync(path.join(home, '.adom', 'session.json'), JSON.stringify({ session_token: 'sess-tok', user: 'x' }));
id = ident();
ok('the Adom Bridge desktop session is used when nothing else is', id.token === 'sess-tok' && /session/.test(id.source), JSON.stringify(id));
fs.mkdirSync(path.join(cfg, 'adom-cli'), { recursive: true });
fs.writeFileSync(path.join(cfg, 'adom-cli', 'config.json'), JSON.stringify({ api_token: 'cli-tok' }));
id = ident();
ok('an adom-cli login beats the session', id.token === 'cli-tok', JSON.stringify(id));
fs.writeFileSync(path.join(tmp, 'key'), 'container-tok\n');
id = ident({ HERO_STUDIO_CONTAINER_KEY: path.join(tmp, 'key') });
ok('the container key beats the adom-cli login', id.token === 'container-tok', JSON.stringify(id));
fs.mkdirSync(path.join(cfg, 'adom-wiki'), { recursive: true });
fs.writeFileSync(path.join(cfg, 'adom-wiki', 'config.json'), JSON.stringify({ api_token: 'wiki-tok' }));
id = ident({ HERO_STUDIO_CONTAINER_KEY: path.join(tmp, 'key') });
ok('the adom-wiki stored token beats the container key', id.token === 'wiki-tok', JSON.stringify(id));
id = ident({ ADOM_WIKI_TOKEN: 'env-tok' });
ok('ADOM_WIKI_TOKEN beats everything', id.token === 'env-tok' && id.source === 'env:ADOM_WIKI_TOKEN', JSON.stringify(id));
// --- import specifiers (#61) ---
const { importSpecifier } = await import(path.join(ROOT, 'doctor.mjs'));
ok('an absolute path becomes a file:// URL for import()', importSpecifier('/x/puppeteer-core.js') === 'file:///x/puppeteer-core.js', importSpecifier('/x/puppeteer-core.js'));
ok('a file:// URL passes through untouched', importSpecifier('file:///C:/x/p.js') === 'file:///C:/x/p.js');
// --- Chrome candidates per OS (#61) ---
const win = RP.chromeCandidates('win32', { ProgramFiles: 'C:\\Program Files', 'ProgramFiles(x86)': 'C:\\Program Files (x86)', LOCALAPPDATA: 'C:\\Users\\u\\AppData\\Local' }, home);
ok('Windows: Program Files Chrome is a candidate', win.includes('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'), win.join(' | '));
ok('Windows: the per-user install is a candidate', win.includes('C:\\Users\\u\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe'));
const cacheDir = path.join(home, '.cache', 'puppeteer', 'chrome', 'win64-150.0.0');
fs.mkdirSync(cacheDir, { recursive: true });
const win2 = RP.chromeCandidates('win32', {}, home);
ok('Windows: the puppeteer cache is read as chrome-win64\\chrome.exe', win2.some((c) => c.endsWith(path.join('chrome-win64', 'chrome.exe'))), win2.join(' | '));
const mac = RP.chromeCandidates('darwin', {}, home);
ok('macOS: /Applications Chrome is a candidate', mac.includes('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'));
const lin = RP.chromeCandidates('linux', {}, home);
ok('Linux: the cache layout and system Chrome are candidates', lin.some((c) => c.includes('chrome-linux64')) && lin.includes('/usr/bin/google-chrome'));
// --- a real server: desktop identity, snapshot path, no stamp tools (#60 #64 #66) ---
const port = await new Promise((r) => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => r(p)); }); });
const binDir = path.join(tmp, 'bin'); fs.mkdirSync(binDir);
// PATH holds only a `node` shim: no python, no python3, no magick, no convert
fs.symlinkSync(process.execPath, path.join(binDir, 'node'));
const srvEnv = { ...baseEnv, PATH: binDir, HERO_STUDIO_PORT: String(port), ADOM_WIKI_REGISTRY: 'http://127.0.0.1:9', HERO_RENDERER: 'local',
CHROME: RP.chromePath() || '', PUPPETEER_CORE: RP.puppeteerPath() || '' };
fs.rmSync(path.join(cfg, 'adom-wiki'), { recursive: true, force: true });
fs.rmSync(path.join(cfg, 'adom-cli'), { recursive: true, force: true });
const srv = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--port', String(port), '--ai-thread', 'platform test', '--ttl', '1'], { env: srvEnv, cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
let log = ''; srv.stdout.on('data', (d) => { log += d; }); srv.stderr.on('data', (d) => { log += d; });
const base = 'http://127.0.0.1:' + port + '/';
let up = false;
for (let i = 0; i < 60 && !up; i++) { try { up = (await fetch(base + 'version')).ok; } catch {} if (!up) await new Promise((r) => setTimeout(r, 200)); }
ok('a server starts with a desktop-only environment', up, log.slice(-300));
try {
const st = await (await fetch(base + 'state')).json();
ok('state.identity names the desktop session, never the token', st.identity && /session/.test(st.identity.source) && st.identity.anonymous === false && !JSON.stringify(st).includes('sess-tok'), JSON.stringify(st.identity));
const expectSnap = path.join(home, '.adom', 'instances', 'hero-studio', 'snapshots');
const con = await (await fetch(base + 'console')).text();
ok('boot logs where it looked for the snapshot, under the real home dir', con.includes(expectSnap), con.slice(0, 400));
const post = async (p, b) => (await fetch(base + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aiThread: 'platform test', ...b }) })).json();
await post('state', { mode: 'component' });
await fetch(base + 'compose.png');
const gen = await post('generate', { fromGate: true, renderer: 'local' });
ok('with no Python/Pillow and no ImageMagick, Generate fails with STAMP_FAILED instead of shipping an unstamped PNG',
gen.ok === false && gen.errorCode === 'STAMP_FAILED', JSON.stringify(gen).slice(0, 300));
ok(' and the refusal says how to fix it', /pillow|Pillow/.test(gen.hint || ''), gen.hint);
} finally {
srv.kill();
fs.rmSync(tmp, { recursive: true, force: true });
}
console.log('\n' + pass + ' passed, ' + fails.length + ' failed');
process.exit(fails.length ? 1 : 0);