/* * 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 \|\| \[\]/); }); });