__tests__/platforms.test.js
Rama main · réplica de lectura del repositorio original.
/*
* THE PLATFORM REGISTRY — one place that says what a platform is and how it may be worked.
*
* These pin the two things that make it worth having. First: on the day it lands NOTHING CHANGES —
* every constant the three services carried is here, with the same value, so behaviour is identical
* and only the ownership moved. Second: the rules that keep an account alive (a first DM only where
* it is ordinary, a pace that is not a crawler) cannot be edited away by an override.
*/
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const tmp = mkdtempSync(join(tmpdir(), 'gb-platforms-'));
process.env.PROFILE_DIR = tmp;
const platforms = require('../src/platforms');
beforeEach(() => { try { if (existsSync(platforms.FILE)) rmSync(platforms.FILE); } catch { /* fresh anyway */ } });
afterAll(() => { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* windows */ } });
describe('what shipped is exactly what the three services carried', () => {
it('keeps the read gaps Herald paced by', () => {
const gap = (k) => platforms.get(k).readGapMs;
expect(gap('reddit')).toBe(25 * 60 * 1000);
expect(gap('linkedin')).toBe(20 * 60 * 1000);
expect(gap('hn')).toBe(20 * 60 * 1000);
expect(gap('web')).toBe(12 * 60 * 1000);
/* Everything else was Herald's 15-minute default. */
expect(gap('facebook')).toBe(15 * 60 * 1000);
});
it('keeps the roles each platform is read and answered with', () => {
expect(platforms.get('reddit').roles.scan).toBe('research.reddit');
expect(platforms.get('linkedin').roles.scan).toBe('research.linkedin');
expect(platforms.get('hn').roles.scan).toBe('research.web');
expect(platforms.get('facebook').roles.reply).toBe('herald.facebook.groups.engage');
});
/*
* READING A ROOM AND SCOUTING FOR PEOPLE ARE TWO JOBS, and folding them into one field silently
* changed behaviour the first time it was tried: the desk's Facebook reads landed on facebook.scout,
* a role that holds save_lead and cannot post, so a room read would have started saving leads
* instead of reporting threads. Herald reads a room and REPORTS; LeadFlow reads for PEOPLE and SAVES.
*/
it('separates reading a room from scouting for people', () => {
const fb = platforms.get('facebook');
expect(fb.roles.scan).toBe('research.web'); // Herald: read the group, report the threads
expect(fb.roles.scout).toBe('facebook.scout'); // LeadFlow: find people, save each as a lead
const li = platforms.get('linkedin');
expect(li.roles.scan).toBe('research.linkedin');
expect(li.roles.scout).toBe('linkedin.scout');
expect(li.roles.dm).toBe('linkedin.conversation');
/* Rooms have a scout. A TOOL — the image workbench — has nobody in it to find, and giving it a
scout role to satisfy a loop would advertise a capability that does not exist. */
for (const r of platforms.list().filter((x) => x.kind !== 'tool')) expect(typeof r.roles.scout).toBe('string');
});
it('places a room the way Herald placed it, in the same order', () => {
expect(platforms.detect('r/webdev')).toBe('reddit');
expect(platforms.detect('https://www.reddit.com/r/SaaS/')).toBe('reddit');
expect(platforms.detect('the Bakkers FB group')).toBe('facebook');
expect(platforms.detect('LinkedIn: DevOps NL')).toBe('linkedin');
expect(platforms.detect('Show HN')).toBe('hn');
expect(platforms.detect('news.ycombinator.com/newest')).toBe('hn');
expect(platforms.detect('Indie Hackers')).toBe('indiehackers');
expect(platforms.detect('a mailing list somebody described in prose')).toBe('web');
});
it('derives the room URLs that are derivable and no others', () => {
expect(platforms.roomUrl('reddit', 'r/webdev')).toBe('https://www.reddit.com/r/webdev/new/');
expect(platforms.roomUrl('hn', 'Show HN')).toBe('https://news.ycombinator.com/newest');
expect(platforms.roomUrl('producthunt', 'Product Hunt')).toBe('https://www.producthunt.com/');
expect(platforms.roomUrl('linkedin', 'DevOps NL')).toBe('');
/* A URL the caller already has always wins. */
expect(platforms.roomUrl('reddit', 'r/webdev', 'https://example.com/x')).toBe('https://example.com/x');
});
it('carries the profile names, INCLUDING the ones a swallowed comment had deleted', () => {
/* herald/src/gb.js had `indiehackers: [], // …comment… producthunt: [...], youtube: [...], x: [...]`
on ONE line: everything after the comment marker was gone, so those platforms had no profile
names at all and every walk on them ran in the shared session, signed out. */
expect(platforms.get('producthunt').profiles).toContain('producthunt');
expect(platforms.get('youtube').profiles).toContain('youtube');
expect(platforms.get('x').profiles).toEqual(expect.arrayContaining(['x', 'twitter']));
expect(platforms.get('google').profiles).toContain('google');
expect(platforms.get('web').profiles).toContain('google');
expect(platforms.get('linkedin').profiles).toEqual(expect.arrayContaining(['linkedin', 'linkdin']));
expect(platforms.get('hn').profiles).toEqual(expect.arrayContaining(['hn', 'hackernews']));
});
it('keeps the Indie Hackers pin, which used to be written twice in two repositories', () => {
expect(platforms.get('indiehackers').loginProfile).toBe('google');
/* It named NO profile of its own and was matched to google only by a site label. Both an
'indiehackers' and the 'google' profile hold its cookies on disk now; the pin still wins. */
expect(platforms.get('indiehackers').profiles).toEqual(['indiehackers']);
});
it('the fallback is last, so detect can never return it early', () => {
const keys = platforms.list().map((r) => r.key);
expect(keys[keys.length - 1]).toBe('web');
});
});
describe('who may be spoken to, and how loudly', () => {
it('a first private message is ordinary only on a business platform', () => {
expect(platforms.get('linkedin').dm).toBe('ok');
expect(platforms.get('facebook').dm).toBe('invited-only');
expect(platforms.get('reddit').dm).toBe('invited-only');
/* Where a stranger's DM lands in a request folder nobody opens, the answer is no, not "gently". */
expect(platforms.get('hn').dm).toBe('never');
expect(platforms.get('producthunt').dm).toBe('never');
expect(platforms.get('x').dm).toBe('never');
});
it('nobody is reached through Google — it is how they are found', () => {
const g = platforms.get('google');
expect(g.kind).toBe('search');
expect(g.reply).toBe(false);
expect(g.dm).toBe('never');
});
it('every record answers all three questions', () => {
for (const r of platforms.list()) {
expect(typeof r.read).toBe('boolean');
expect(typeof r.reply).toBe('boolean');
expect(['ok', 'invited-only', 'never']).toContain(r.dm);
/* A read gap is a politeness rule for a room somebody else owns. A tool is the owner's own
workbench, read on demand, so the floor applies to rooms only. */
if (r.kind !== 'tool') expect(r.readGapMs).toBeGreaterThanOrEqual(60 * 1000);
}
});
/*
* A TOOL IS IN THE REGISTRY FOR ONE REASON: to say which browser profile holds its login.
*
* A picture walk dispatched at "platform: google" had no record here, so the lookup found nothing,
* fell through to the single-browser default and opened the image generator in the FACEBOOK jar.
* The account that is actually signed in — PRO, with the image models on it — lives in the
* googleaistudio profile. That belongs written down once, where every caller reads it.
*
* What it must NOT do is look like a room. Nothing is read there, nothing is replied to, no brand
* has a presence on a workbench, and a record that quietly defaulted to friendlier values would put
* it in front of a desk looking for somewhere to post.
*/
it('the image workbench is a tool, and says so in every field that matters', () => {
const t = platforms.get('googleaistudio');
expect(t.kind).toBe('tool');
expect(t.loginProfile).toBe('googleaistudio');
expect(t.read).toBe(false);
expect(t.reply).toBe(false);
expect(t.dm).toBe('never');
expect(t.roles.scan).toBeNull();
expect(t.roles.scout).toBeNull();
});
it('routes Google Flow to the Google AI Studio login profile as a tool', () => {
const flow = platforms.get('googleflow');
expect(flow.start).toBe('https://labs.google/fx/tools/flow');
expect(flow.loginProfile).toBe('googleaistudio');
expect(flow.kind).toBe('tool');
expect(flow.read).toBe(false);
expect(flow.reply).toBe(false);
expect(flow.roles.scan).toBeNull();
});
});
describe('the owner can change the rules without a deploy — but not break them', () => {
it('takes a partial patch and leaves the rest of the record alone', () => {
platforms.override('reddit', { repliesPerDay: 1 });
expect(platforms.get('reddit').repliesPerDay).toBe(1);
expect(platforms.get('reddit').roles.scan).toBe('research.reddit');
expect(platforms.get('reddit').readGapMs).toBe(25 * 60 * 1000);
});
it('refuses a pace that is a crawler rather than a pace', () => {
platforms.override('reddit', { readGapMs: 1000 });
expect(platforms.get('reddit').readGapMs).toBe(25 * 60 * 1000);
platforms.override('reddit', { readGapMs: 40 * 60 * 1000 });
expect(platforms.get('reddit').readGapMs).toBe(40 * 60 * 1000);
});
it('refuses a dm setting that is not one of the three answers', () => {
platforms.override('hn', { dm: 'sure why not' });
expect(platforms.get('hn').dm).toBe('never');
platforms.override('hn', { dm: 'invited-only' });
expect(platforms.get('hn').dm).toBe('invited-only');
});
it('ignores a field nobody may set from outside', () => {
platforms.override('reddit', { key: 'evil', site: 'evil.example' });
expect(platforms.get('reddit').site).toBe('reddit.com');
expect(platforms.get('evil')).toBe(null);
});
it('resets to what shipped', () => {
platforms.override('reddit', { repliesPerDay: 9 });
expect(platforms.get('reddit').repliesPerDay).toBe(9);
expect(platforms.reset('reddit')).toBe(true);
expect(platforms.get('reddit').repliesPerDay).toBe(2);
});
it('a platform that does not exist is a 404, not a new record', () => {
expect(() => platforms.override('tiktok', { dm: 'ok' })).toThrow(/no such platform/);
});
});
describe('the tab can say whether we are even signed in', () => {
it('a profile named after the platform counts', () => {
const rows = platforms.withLogins([{ name: 'reddit' }, { name: 'google' }]);
const reddit = rows.find((r) => r.key === 'reddit');
expect(reddit.signedIn).toBe(true);
expect(reddit.servedBy).toBe('reddit');
expect(rows.find((r) => r.key === 'linkedin').signedIn).toBe(false);
});
it('a profile LABELLED with the site counts, whatever it is called', () => {
const rows = platforms.withLogins([{ name: 'carla-test-facebook', site: 'facebook.com' }]);
const fb = rows.find((r) => r.key === 'facebook');
expect(fb.signedIn).toBe(true);
expect(fb.servedBy).toBe('carla-test-facebook');
});
it('Indie Hackers is signed in when GOOGLE is, because that is where its login lives', () => {
const rows = platforms.withLogins([{ name: 'google' }]);
expect(rows.find((r) => r.key === 'indiehackers').signedIn).toBe(true);
});
it('remembers when a platform was last used and what it last refused', () => {
platforms.note('reddit', { used: true });
platforms.note('reddit', { refusedWhy: 'asked us to slow down' });
const r = platforms.withLogins([]).find((x) => x.key === 'reddit');
expect(r.lastUsedAt).toMatch(/^\d{4}-/);
expect(r.lastRefusal).toBe('asked us to slow down');
expect(r.lastRefusalAt).toMatch(/^\d{4}-/);
});
it('health notes never turn into a platform of their own', () => {
platforms.note('reddit', { used: true });
expect(platforms.list().some((r) => r.key === '_health')).toBe(false);
});
});
/*
* THE OWNER'S CONSOLE SEES THE OWNER'S BROWSER.
*
* Live: a Maps walk was 74 steps into a search on the google profile and the console's LIVE NOW said
* "Nothing running right now" — so "watch it work" opened a page insisting nothing was happening.
* A caller's identity comes from its key (LeadFlow's is "gb_42c3f"); the console signs in as the
* PERSON. listFor(owner) therefore hid the owner's own browser from the one human who owns all of it.
*/
describe('what the console is entitled to see', () => {
const server = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/server.js'), 'utf8');
const jobsSrc = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/jobs.js'), 'utf8');
const poolSrc = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/pool.js'), 'utf8');
it('marks a cookie-signed-in request as the console, not as a key', () => {
expect(server).toMatch(/req\.client = \{ owner: who\.username, console: true,/);
});
it('lists every session and every job for the console — and only its own for a key', () => {
/* The entitlement is unchanged; each session is now enriched with what holds it, so the choice
sits on its own line rather than inline in the response. */
expect(server).toMatch(/const list = \(req\.client\.console \? pool\.listAll\(\) : pool\.listFor\(req\.client\.owner\)\)/);
/* The list is now mapped over to mark the queued ones, so the choice sits one line up. */
expect(server).toMatch(/const list = req\.client\.console \? jobs\.listAll\(\) : jobs\.listFor\(req\.client\.owner\);/);
/* The key path must stay scoped: one connected tool still cannot read another's work. */
expect(jobsSrc).toMatch(/const listFor = \(owner\) => \[\.\.\.jobs\.values\(\)\]\.filter\(\(j\) => j\.owner === owner\)/);
});
it('both listings exist and carry which profile the work is on', () => {
expect(jobsSrc).toMatch(/const listAll = \(\)/);
expect(jobsSrc).toMatch(/profile: j\.profile \|\| null/);
expect(poolSrc).toMatch(/listAll\(\) \{/);
expect(poolSrc).toMatch(/sessionId: id, url, profile: s\.profile \|\| null, owner: s\.owner \|\| null/);
});
it('listAll is exported from both', () => {
const jobs = require('../src/jobs.js');
expect(typeof jobs.listAll).toBe('function');
expect(Array.isArray(jobs.listAll())).toBe(true);
});
});
/*
* THE SESSION QUEUE — one conversation at a time, and everyone else waits their turn.
*
* Live: four approved Herald replies and a planned post all died on
* "409: that session already has a conversation — say something in it, or stop it first".
* The session was held by a job belonging to ANOTHER organ's key, and a key lists only its own jobs
* — so Herald could not see the holder, could not stop it, and had nothing to do but fail. A dead
* end built from two reasonable rules. The resource lives here, so the queue does too.
*/
describe('a busy session queues the next job instead of refusing it', () => {
const server = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/server.js'), 'utf8');
it('no longer answers a second job with a 409', () => {
expect(server).not.toMatch(/that session already has a conversation — say something in it, or stop it first/);
expect(server).toMatch(/const free = sessionFree\(session\);/);
});
it('never interrupts a walk that is running', () => {
const fn = server.slice(server.indexOf('function sessionFree('), server.indexOf('/** Start whatever is next in line'));
expect(fn).toMatch(/if \(!held \|\| jobs\.isOver\(held\)\) return \{ free: true \};/);
/* Only an IDLE job — finished and merely parked — is taken over. */
expect(fn).toMatch(/if \(held\.status === 'idle' && !pending\) return \{ free: true, release: held\.id \};/);
expect(fn).toMatch(/return \{ free: false, heldBy: held\.id/);
});
it('never discards an act the owner still owes a decision on', () => {
const fn = server.slice(server.indexOf('function sessionFree('), server.indexOf('/** Start whatever is next in line'));
expect(fn).toMatch(/const pending = \(held\.proposals \|\| \[\]\)\.some\(\(p\) => p && p\.state === 'pending'\);/);
expect(fn).toMatch(/it is waiting for your decision on an act/);
});
it('claims the session only when the job actually starts', () => {
/* Claiming it at creation would take the page from the walk still clicking in it — the exact
thing the queue exists to prevent. */
expect(server).toMatch(/The session is claimed WHEN THE JOB ACTUALLY STARTS/);
/* Three ordered facts rather than one multi-line regex: this file is CRLF, so a needle carrying
a plain newline matches nothing and the assertion passes for the wrong reason. */
const block = server.slice(server.indexOf('if (free.free) {'), server.indexOf('const q = queueFor(sessionId);'));
expect(block).toContain('session.job = job.id;');
expect(block).toContain('launch();');
expect(block.indexOf('session.job')).toBeLessThan(block.indexOf('launch()'));
});
it('answers 202 with a place in the line, so a caller can say "second, not failed"', () => {
expect(server).toMatch(/return res\.status\(202\)\.json\(\{/);
expect(server).toMatch(/queued: true, position: q\.length, heldBy: free\.heldBy, why: free\.why,/);
});
it('a queued job reads as queued when it is polled', () => {
expect(server).toMatch(/const place = queuePosition\(j\.id\);/);
expect(server).toMatch(/\.\.\.\(place \? \{ queued: true, position: place \} : \{\}\)/);
});
it('is pumped on a timer, because a job can end five different ways', () => {
expect(server).toMatch(/setInterval\(\(\) => \{ pumpQueues\(\)\.catch\(\(\) => \{\}\); \}, 5000\)/);
expect(server).toMatch(/a queue that hooks four of those five is a queue/);
});
it('gives up honestly rather than holding work for ever', () => {
expect(server).toMatch(/const QUEUE_TTL_MS = 60 \* 60 \* 1000;/);
expect(server).toMatch(/waited over an hour for the browser and gave up/);
expect(server).toMatch(/the browser session it was waiting for was closed/);
expect(server).toMatch(/const QUEUE_MAX = 20;/);
});
});
/*
* THE IMAGE IS BUILT FROM `git archive` ON A WINDOWS CHECKOUT.
*
* git archive applies the worktree end-of-line conversion, so entrypoint.sh left the repo with CRLF
* even though the stored blob is LF. A shebang ending in a carriage return is not a path to any
* interpreter, and v195 crashlooped on both pods with
*
* exec /usr/local/bin/entrypoint.sh: no such file or directory
*
* The browser was down until v196. .gitattributes pins the files the Linux image RUNS.
*/
describe('what the image runs keeps unix line endings', () => {
const attrs = require('node:fs').readFileSync(require('node:path').join(__dirname, '../.gitattributes'), 'utf8');
it('pins every shell script, however it is checked out or exported', () => {
expect(attrs).toMatch(/^\*\.sh text eol=lf$/m);
});
it('pins the entrypoint by name — it is the one that took the pod down', () => {
expect(attrs).toMatch(/^entrypoint\.sh text eol=lf$/m);
});
it('the entrypoint the image copies in has no carriage returns', () => {
const sh = require('node:fs').readFileSync(require('node:path').join(__dirname, '../entrypoint.sh'), 'utf8');
expect(sh.startsWith('#!/bin/sh\n')).toBe(true);
expect(sh.includes(String.fromCharCode(13))).toBe(false);
});
});
/*
* A DRAINED POD THAT CANNOT RECYCLE IS A DEAD SERVICE.
*
* Carla cleared Herald for a clean start and its first read came back "this worker is draining -
* retry, another will take it". There is no other worker. The pod had latched draining at 80% memory
* hours before and then sat at 67%, which is Chromium's floor with a couple of sessions open and
* above the un-latch threshold of 65% (the ceiling less the 15-point hysteresis). Two IDLE sessions
* kept the count above zero, so the "drained, exit, be replaced" path never fired either. Not
* accepting work and not recycling, for hours.
*/
describe('a draining pod gets itself replaced', () => {
const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/pool.js'), 'utf8');
it('closes its idle sessions instead of waiting five minutes for each to age out', () => {
expect(src).toMatch(/if \(this\.draining\) \{\s*\n\s*for \(const \[id, s\] of \[\.\.\.this\.sessions\]\) \{/);
expect(src).toMatch(/now - s\.lastUsed > DRAIN_IDLE_MS/);
});
it('but leaves a grace, because a walk between two slow steps has not stopped using its session', () => {
expect(src).toMatch(/const DRAIN_IDLE_MS = 2 \* 60 \* 1000;/);
});
it('and the ordinary idle limit is unchanged for a pod that is NOT draining (20 min since 2026-11-07; a closed one reopens on use)', () => {
expect(src).toMatch(/idleMs:\s+NUM\(process\.env\.IDLE_MS, 20 \* 60 \* 1000\)/);
});
it('the closing still happens before the exit check, so zero is reached on the same sweep', () => {
const drainClose = src.indexOf('now - s.lastUsed > DRAIN_IDLE_MS');
const exit = src.indexOf('drained; exiting so the pod is replaced');
expect(drainClose).toBeGreaterThan(0);
expect(exit).toBeGreaterThan(drainClose);
});
it('and the un-latch on genuinely recovered memory is still there', () => {
expect(src).toMatch(/accepting again at \$\{cap\.memoryPct\}%/);
});
});
/*
* WHICH DOORS ARE SHUT TO A STRANGER. Herald spent a browser session discovering that a Discord
* server needs an account, and would have spent one every morning after. Whether READING a platform
* needs a signed-in profile is a fact about the platform, so it lives on the record with the rest.
*/
describe('the registry says which platforms need a sign-in to read', () => {
const by = Object.fromEntries(platforms.SEED.map((r) => [r.key, r]));
it('the walled ones: an account or you see a login wall', () => {
for (const k of ['discord', 'facebook', 'linkedin', 'x']) expect({ [k]: by[k].readNeedsLogin }).toEqual({ [k]: true });
});
it('the open ones: a subreddit, a Show HN, a search result', () => {
for (const k of ['reddit', 'hn', 'google', 'web', 'indiehackers', 'producthunt']) expect({ [k]: by[k].readNeedsLogin }).toEqual({ [k]: false });
});
it('every record answers it, so a caller never has to guess from undefined', () => {
for (const r of platforms.SEED) expect({ key: r.key, t: typeof r.readNeedsLogin }).toEqual({ key: r.key, t: 'boolean' });
});
it('and the owner can override it, like the rest of a record', () => {
expect(platforms.OVERRIDABLE.has('readNeedsLogin')).toBe(true);
});
/* signedIn is the other half of the answer, and withLogins already provides it. */
it('it rides out beside signedIn, which is what makes the pair usable', () => {
const rows = platforms.withLogins([{ name: 'reddit' }]);
const reddit = rows.find((r) => r.key === 'reddit');
const discord = rows.find((r) => r.key === 'discord');
expect(reddit.signedIn).toBe(true);
expect(discord.signedIn).toBe(false);
expect(discord.readNeedsLogin).toBe(true);
});
});
/*
* "OPEN AT MOST 15 PAGES" WAS A REQUEST, NOT A LIMIT.
*
* Herald's daily read of one Facebook group ran two hours and twenty-nine minutes, 123 tool calls,
* 29 distinct pages, on a brief of fifteen. The trail shows the drift: the six search phrases it was
* given, then twenty more it invented (Stripe GitHub, blocked deceptive, URGENT locked, code audit),
* and member profile pages that were never in the brief. Each page was defensible; the read was not.
*
* Nothing counted pages. The only hard number was a GLOBAL maxSteps of 120 shared by a five-minute
* room skim and a two-hour research walk, and one page costs several steps.
*/
describe('a walk cannot spend more pages than it was given', () => {
const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/agent.js'), 'utf8');
const server = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/server.js'), 'utf8');
const jobsSrc = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/jobs.js'), 'utf8');
it('counts pages from the address bar, so every way of moving counts and a re-read does not', () => {
expect(src).toMatch(/const u = page\(\)\.url\(\); if \(u && u !== lastPageUrl\) \{ lastPageUrl = u; pagesSpent\+\+; \}/);
});
/* Asking it to stop is what the old step budget did, and this walk sailed past that too. */
it('REFUSES the tools that go somewhere new, rather than asking nicely', () => {
expect(src).toMatch(/const GOES_SOMEWHERE_NEW = new Set\(\['open', 'dig', 'google'\]\);/);
expect(src).toMatch(/if \(maxPages && pagesSpent >= maxPages && GOES_SOMEWHERE_NEW\.has\(call\.name\)\) \{/);
expect(src).toMatch(/refused — the \$\{maxPages\}-page budget for this job is spent/);
});
it('but leaves everything needed to WRITE THE REPORT open', () => {
for (const t of ['look', 'read', 'scroll', 'sweep', 'finish']) expect({ t, blocked: /GOES_SOMEWHERE_NEW = new Set\(\[[^\]]*'' \+ t/.test(src) }).toEqual({ t, blocked: false });
expect(src).not.toMatch(/GOES_SOMEWHERE_NEW = new Set\(\[[^\]]*finish/);
});
it('and asks for the report the moment the budget is spent', () => {
expect(src).toMatch(/if \(maxPages && pagesSpent >= maxPages && !wrappingUp\) \{/);
expect(src).toMatch(/-page budget spent \(\$\{pagesSpent\} pages\)/);
});
it('the budgets are per JOB, with the global as the default', () => {
expect(src).toMatch(/const maxSteps = Number\(job\.maxSteps\) > 0 \? Number\(job\.maxSteps\) : settings\.maxSteps;/);
expect(src).toMatch(/const maxPages = Number\(job\.maxPages\) > 0 \? Number\(job\.maxPages\) : 0;/);
expect(jobsSrc).toMatch(/maxSteps: Number\(maxSteps\) > 0 \? Number\(maxSteps\) : 0,/);
});
/* 0 pages means uncounted: every walk that existed before budgets behaves exactly as it did. */
it('a job with no budget is the old behaviour, not a job with none left', () => {
expect(src).toMatch(/0 = uncounted, the old behaviour/);
});
it('a caller cannot buy an unbounded walk by typing a big number', () => {
expect(server).toMatch(/maxSteps: Math\.min\(300, Math\.max\(0, Math\.round\(Number\(maxSteps\) \|\| 0\)\)\)/);
expect(server).toMatch(/maxPages: Math\.min\(200, Math\.max\(0, Math\.round\(Number\(maxPages\) \|\| 0\)\)\)/);
});
it('and the messages quote the budget the walk is actually held to', () => {
expect(src).not.toMatch(/budget of \$\{settings\.maxSteps\} steps/);
expect(src).toMatch(/You have used your whole budget of \$\{maxSteps\} steps/);
});
});
/* A walk was charged a page for standing still: lastPageUrl started empty, so the count moved on the
very first turn before anything was opened, and a 15-page brief was really 14. */
describe('the page budget starts where the walk starts', () => {
const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/agent.js'), 'utf8');
it('seeds the counter with the page the session is already on', () => {
expect(src).toMatch(/let lastPageUrl = \(\(\) => \{ try \{ return page\(\)\.url\(\) \|\| ''; \} catch \{ return ''; \} \}\)\(\);/);
});
it('and a session with no page yet does not throw on the way in', () => {
expect(src).toMatch(/catch \{ return ''; \}/);
});
});
/*
* CAPACITY WAS OVERSOLD THREE WAYS, AND THE ORGANS WERE CAPPED LIKE CUSTOMERS.
*
* Every reply Herald sent in one afternoon failed, and the owner's own console went black opening
* Indie Hackers. One pod, three numbers that disagreed about how many browsers it could hold: the pool
* ADVERTISED 8 (a typed default); the one 'gb_' key the provisioner mints per cluster is ':team', so
* Herald, LeadFlow and the master TOGETHER got 3; and a named profile is a whole Chromium at ~1.1 GiB,
* so three of them put the 4 GiB pod at its 80% guard and it started draining — refusing everyone.
* Three reads on three profiles filled it; the reply on a fourth profile got "your plan allows 3" and
* a 503, and improvised. The owner got the 503 too, queued behind her own organs.
*/
describe('the session ceiling is what memory affords, not a typed number', () => {
const pool = require('../src/pool');
const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/pool.js'), 'utf8');
it('derives maxContexts from the memory limit at ~1.15 GiB per browser', () => {
expect(src).toMatch(/maxContexts:\s+NUM\(process\.env\.MAX_CONTEXTS, contextsMemoryAffords\(\)\)/);
expect(src).toMatch(/const PER_BROWSER = 1\.15 \* 1024 \* 1024 \* 1024;/);
expect(pool.LIMITS.maxContexts).toBeGreaterThanOrEqual(1);
});
/* A 4 GiB pod says 3, a 6 GiB pod says 5 — refused cleanly at the ceiling, never drained at 80%. */
it('never says zero, and MAX_CONTEXTS still overrides for an install that knows better', () => {
expect(src).toMatch(/Math\.max\(1, Math\.floor\(memoryLimitBytes\(\) \/ PER_BROWSER\)\)/);
expect(src).toMatch(/process\.env\.MAX_CONTEXTS/);
});
});
describe('the owner keeps one slot', () => {
const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/pool.js'), 'utf8');
const server = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/server.js'), 'utf8');
it('a reserved session may go one past the ceiling', () => {
expect(src).toMatch(/reserved = false(, id: reuseId = null)? \} = \{\}\) \{/); // id: reopening a timed-out session (sessionRevive.js)
expect(src).toMatch(/this\.sessions\.size >= this\.limits\.maxContexts \+ \(reserved \? 1 : 0\)/);
});
it('and only the console asks for it', () => {
expect(server).toMatch(/reserved: !!req\.client\.console,/);
});
/* A slot is not a licence to be OOM-killed: the memory guard sits ABOVE the reserved check. */
it('memory still applies to a reserved session', () => {
const mem = src.indexOf('this worker is at its memory ceiling');
const slot = src.indexOf('this.limits.maxContexts + (reserved ? 1 : 0)');
expect(mem).toBeGreaterThan(0);
expect(mem).toBeLessThan(slot);
});
});
describe('a platform-minted organ key is not a customer', () => {
const { parseKeys, PLANS } = require('../src/keys');
it('a gb_ key is held to the pod, whatever plan it was minted on', () => {
const m = parseKeys('gb_0123456789abcdef:team');
expect([...m.values()][0].maxConcurrent).toBe(64);
});
it('in the JSON form too', () => {
const m = parseKeys(JSON.stringify({ gb_0123456789abcdef: { plan: 'team', owner: 'herald' } }));
expect([...m.values()][0].maxConcurrent).toBe(64);
});
/* The plans are still the lever for everyone else — this is about the platform's own organs. */
it('a customer key keeps its plan', () => {
const m = parseKeys('cust_0123456789abcdef:team');
expect([...m.values()][0].maxConcurrent).toBe(PLANS.team.maxConcurrent);
});
});
describe('the registry names profiles that exist on the disk', () => {
const by = Object.fromEntries(platforms.SEED.map((r) => [r.key, r]));
/* It had an empty list and was matched to google only by a site label; both profiles hold its cookies. */
it('indiehackers names its own profile, and the pin to google still wins', () => {
expect(by.indiehackers.profiles).toEqual(['indiehackers']);
expect(by.indiehackers.loginProfile).toBe('google');
});
});
/*
* ── "NOT YOUR SESSION", IN YOUR OWN BROWSER ──────────────────────────────────────────────────────
*
* The owner watched a Google login sit parked on a sign-in page for twenty minutes. They needed to
* sign in — the whole search loop was blocked on it — and could not, because the session had been
* opened by an organ's key rather than by them. The console listed it and refused to close it.
*
* Two things were wrong. A key may only close what it opened, which is right and stays; but the
* CONSOLE is the signed-in owner of this install, and refusing them was a rule with nobody behind
* it. And the screen could not say what held the session anyway: every organ shares ONE minted key,
* so the owner field is the same eight characters for the master, Herald and LeadFlow alike.
*
* A session's real identity is the JOB in it — reach.search is Search Console, learn.shot is a
* picture for an answer page — and the job's status says whether it is working or has finished and
* is merely holding the door shut.
*/
describe('the owner can see and close what is holding their browser', () => {
const server = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/server.js'), 'utf8');
const dash = require('node:fs').readFileSync(require('node:path').join(__dirname, '../public/js/dashboard.js'), 'utf8');
it('each session says which job holds it, and whether that job is still running', () => {
expect(server).toMatch(/function heldBy\(sessionId\)/);
expect(server).toMatch(/return \{ jobId: live\.id, role: live\.role \|\| 'general', status: live\.status/);
expect(server).toMatch(/job: heldBy\(s\.sessionId\), yours: s\.owner === req\.client\.owner/);
});
/* The live one if there is one, otherwise the most recent — a finished job still holding a
session is exactly the thing worth seeing, so it is never hidden. */
it('and a finished job holding a session is reported, not hidden', () => {
expect(server).toMatch(/j\.find\(\(x\) => x\.status === 'running'\) \|\| j\.sort/);
});
it('the console may close any session in its own browser; a key still may not', () => {
expect(server).toMatch(/if \(s\.owner !== req\.client\.owner && !req\.client\.console\) return res\.status\(403\)/);
/* Said out loud in the close reason, so it is not a silent override. */
expect(server).toMatch(/the owner of this browser/);
});
it('and the card names what holds a login instead of saying only that one is open', () => {
expect(dash).toMatch(/finished and is still holding this login/);
expect(dash).toMatch(/opened by /);
});
});
/*
* ── THREE CARDS SAID LIVE, ONE BROWSER SESSION EXISTED, AND STOP DID NOTHING ─────────────────────
*
* "Live" counted every job that was running OR idle. Idle is what a walk becomes when it has
* FINISHED — the conversation stays open so it can be resumed — and such a job usually holds no
* session at all. So the dashboard showed three live platforms while the pool held one session, and
* pressing Stop on the other two did nothing AND said nothing: no session to close (the id was
* empty, so the call was skipped) and no running job to end (parked ones were filtered out of
* stopJobs). No error, no change, still "live". It read as a broken button.
*
* Three separate faults, and all three had to go: idle counted as live, Stop could not act on a
* parked job, and a stop that did nothing said nothing.
*/
describe('live means a browser is busy, and stop always reports what it did', () => {
const dash = require('node:fs').readFileSync(require('node:path').join(__dirname, '../public/js/dashboard.js'), 'utf8');
it('only a running job makes a profile live; a parked one never does', () => {
expect(dash).toMatch(/const runningJobs = allJobs\.filter\(j => j\.status === 'running'\)/);
expect(dash).toMatch(/const parkedJobs = allJobs\.filter\(j => j\.status === 'idle'\)/);
expect(dash).toMatch(/for \(const j of runningJobs\) \{ if \(j\.profile\) \{ active\.add\(j\.profile\)/);
/* The old rule, which is what produced three live cards over one session. */
expect(dash).not.toMatch(/filter\(j => j\.status === 'running' \|\| j\.status === 'idle'\)/);
});
it('a parked conversation is shown as parked, not as the browser being busy', () => {
expect(dash).toMatch(/finished conversations are/);
expect(dash).toMatch(/nothing is running/);
});
/* Stop is offered over parked jobs, so it must be able to end them. */
it('stop ends parked jobs as well as running ones', () => {
expect(dash).toMatch(/const stopJobs = liveJobs\.filter\(j => j\.status === 'running'\)\.concat\(parkedHere\)/);
});
it('and a stop with nothing to act on says so instead of going quiet', () => {
expect(dash).toMatch(/there is no open session and no job to end/);
expect(dash).toMatch(/if \(!ended && !closed\) alert\(/);
});
});
/*
* ── A LIST THE VIEW DOES NOT NAME IS INVISIBLE, NOT EMPTY ────────────────────────────────────────
*
* A Search Console audit called save_gsc_health six times. The store pushed all six onto the job and
* persisted it — and `persistable()`, which is both what gets written to disk and what every reader
* is handed, did not name gscHealth. The findings lived exactly as long as the process did. The
* master asked for them, got nothing, and recorded "the audit read nothing it could file": honest,
* and completely wrong. It had read plenty.
*
* What makes this worth a permanent guard rather than a fix is that it is silent BY CONSTRUCTION. A
* missing key reads as an empty list, and an empty list is an ordinary outcome for a young property —
* so nothing anywhere could tell "found nothing" from "lost everything". The next list added to a job
* will be added the same way, by someone who has no reason to know this file has two places to edit.
*/
describe('every list a tool can fill survives the trip to disk', () => {
const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/jobs.js'), 'utf8');
it('the view names every array the store appends to', () => {
/* `j.x = j.x || []` is how each of these begins — the store's own way of saying "a list". */
const lists = [...new Set([...src.matchAll(/j\.(\w+) = j\.\1 \|\| \[\];/g)].map((m) => m[1]))];
expect(lists.length).toBeGreaterThan(4);
/*
* To the END of the object, not a fixed number of characters.
*
* This used to slice 1,800 characters and call it persistable. Adding a comment inside the
* object then pushed the last fields out of that window, and the guard reported lists as
* lost that were sitting right there — which teaches you to distrust the guard, on the one
* test written because a silently dropped field cost six audit findings.
*/
const NL = String.fromCharCode(10);
const body = src.slice(src.indexOf('const persistable'));
const stop = body.indexOf(NL + '});');
const view = stop > 0 ? body.slice(0, stop) : body;
const missing = lists.filter((k) => !view.includes(`${k}:`));
expect(missing, `these lists are written to the job but never returned: ${missing.join(', ')}`).toEqual([]);
});
/* The one that was lost, named, so the fix cannot be quietly reverted. */
it('and the audit findings in particular', () => {
expect(src).toMatch(/gscHealth: j\.gscHealth \|\| \[\]/);
});
});