__tests__/gsc-watch.test.js
Rama main · réplica de lectura del repositorio original.
/*
* THE SEARCH CONSOLE WATCHER'S RULES, without a console, a Chromium or a Pulse.
*
* Three of these tests exist because of things that actually went wrong:
*
* A FEED ROW KEYED ON A CHANGING TITLE FILLS THE PAGE WITH HISTORY. The feed keys an item on its
* url plus its title, so "not indexed — 12" as a title means the next pass adds a second row rather
* than updating the first. The number lives in the fields for exactly that reason.
*
* STALE SHOWN AS CURRENT. Pulse displayed a 2026-09-10 reading for eleven days as though it were
* today's, because nothing ever asked which day the stored reading was from. Only today counts.
*
* A WALK STOPPED HALFWAY STILL READ SOMETHING. Findings accumulate on the job as they are read, so
* the collector takes whatever is there and never requires a clean finish.
*/
import { describe, it, expect } from 'vitest';
import { createRequire } from 'module';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const gsc = require('../src/gscWatch');
const PROPERTY = 'https://my-app.engineer/';
const runWith = (...jobIds) => ({ steps: jobIds.map((id) => ({ output: { __jobId: id } })) });
const jobs = (map) => (id) => map[id] || null;
describe('the addresses the walk is handed', () => {
it('covers all six tabs, each with its own address', () => {
const tabs = gsc.CONSOLE_TABS(PROPERTY);
expect(tabs).toHaveLength(6);
expect(tabs.map((t) => t.key)).toEqual(['indexing', 'sitemap', 'manual_action', 'security', 'message', 'vitals']);
for (const t of tabs) expect(t.url).toMatch(/^https:\/\/search\.google\.com\/search-console\//);
});
it('scopes every property tab to the property, encoded', () => {
for (const t of gsc.CONSOLE_TABS(PROPERTY)) {
if (t.key === 'message') continue;
expect(t.url).toContain('resource_id=' + encodeURIComponent(PROPERTY));
}
});
/* Given a resource_id this tab answers 404. The walk found that out; the address stays account-wide. */
it('leaves messages account-wide, because the property-scoped address 404s', () => {
const msg = gsc.CONSOLE_TABS(PROPERTY).find((t) => t.key === 'message');
expect(msg.url).toBe('https://search.google.com/search-console/messages');
expect(msg.url).not.toContain('resource_id');
});
it('puts the property and every address in the goal, and says read-only', () => {
const goal = gsc.auditGoal({ app: 'my-app.engineer', property: PROPERTY });
expect(goal).toContain(PROPERTY);
for (const t of gsc.CONSOLE_TABS(PROPERTY)) expect(goal).toContain(t.url);
expect(goal).toMatch(/[Rr]ead-only/);
expect(goal).toMatch(/save_gsc_health/);
});
});
describe('collecting what the walk wrote down', () => {
it('takes the findings off every job the run produced', () => {
const run = runWith('j1', 'j2');
const f = gsc.findingsOf(run, jobs({
j1: { gscHealth: [{ kind: 'indexing', label: 'indexed', value: '4' }] },
j2: { gscHealth: [{ kind: 'sitemap', label: 'sitemap.xml', value: 'Geslaagd' }] },
}));
expect(f).toHaveLength(2);
});
/* A walk that re-reads a tab and sees 11 become 12 reports 12, not both. */
it('keeps the last reading of the same row, not two of them', () => {
const f = gsc.findingsOf(runWith('j1'), jobs({
j1: { gscHealth: [{ kind: 'indexing', label: 'not indexed', value: '11' }, { kind: 'indexing', label: 'not indexed', value: '12' }] },
}));
expect(f).toHaveLength(1);
expect(f[0].value).toBe('12');
});
it('treats the same label under two kinds as two findings', () => {
const f = gsc.findingsOf(runWith('j1'), jobs({
j1: { gscHealth: [{ kind: 'indexing', label: 'status', value: 'processing' }, { kind: 'vitals', label: 'status', value: 'not enough data' }] },
}));
expect(f).toHaveLength(2);
});
it('drops what could never be filed, rather than carrying it to Pulse', () => {
const f = gsc.findingsOf(runWith('j1'), jobs({
j1: { gscHealth: [{ kind: 'indexing', label: '' }, { label: 'orphan' }, null, { kind: 'sitemap', label: 'ok', value: 'x' }] },
}));
expect(f).toHaveLength(1);
expect(f[0].label).toBe('ok');
});
it('survives a run whose job is gone, and one that read nothing', () => {
expect(gsc.findingsOf(runWith('j1'), jobs({}))).toEqual([]);
expect(gsc.findingsOf(runWith('j1'), () => { throw new Error('no such job'); })).toEqual([]);
expect(gsc.findingsOf(null, jobs({}))).toEqual([]);
expect(gsc.findingsOf({ steps: [{ output: null }, {}] }, jobs({}))).toEqual([]);
});
});
describe('the rows the results page shows', () => {
const findings = [
{ kind: 'indexing', label: 'not indexed', value: '12', detail: 'Google chose a different canonical' },
{ kind: 'manual_action', label: 'manual actions', value: 'Geen problemen gedetecteerd' },
{ kind: 'sitemap', label: 'none submitted', value: '' },
];
/* The whole reason the value is a field: a row must be the SAME row next pass. */
it('titles a row by its name only, so the next pass updates it instead of adding one', () => {
const rows = gsc.feedRowsFor(findings, { property: PROPERTY });
const notIndexed = rows.find((r) => r.title === 'not indexed');
expect(notIndexed.title).toBe('not indexed');
expect(notIndexed.title).not.toContain('12');
expect(notIndexed.fields.value).toBe('12');
const later = gsc.feedRowsFor([{ kind: 'indexing', label: 'not indexed', value: '9' }], { property: PROPERTY })[0];
expect(later.title).toBe(notIndexed.title);
expect(later.url).toBe(notIndexed.url); // same key → the feed updates one row
expect(later.fields.value).toBe('9');
});
it('carries no draft and is marked read-only, so the card offers nothing to press', () => {
for (const r of gsc.feedRowsFor(findings, { property: PROPERTY })) {
expect(r.draft).toBe('');
expect(r.readOnly).toBe(true);
}
});
it('links each row back to the tab it was read on', () => {
const rows = gsc.feedRowsFor(findings, { property: PROPERTY });
expect(rows.find((r) => r.kind === 'sitemap').url).toContain('/sitemaps');
expect(rows.find((r) => r.kind === 'manual_action').url).toContain('/manual-actions');
});
/*
* THE FEED DOES THE SORTING, so the rank has to be in the field it sorts on. Sorting this array was
* not enough: the feed re-sorts by urgency and then by firstSeen, every row lands in the same
* millisecond, and one urgency for all of them broke the tie on insert order — reversed.
*/
it('leads with the finding that makes every other number irrelevant', () => {
const rows = gsc.feedRowsFor(findings, { property: PROPERTY });
expect(rows[0].kind).toBe('manual_action');
});
it('ranks by urgency, which is what the feed orders on', () => {
const rows = gsc.feedRowsFor(findings, { property: PROPERTY });
const by = Object.fromEntries(rows.map((r) => [r.kind, r.urgency]));
expect(by.manual_action).toBeGreaterThan(by.indexing);
expect(by.indexing).toBeGreaterThan(by.sitemap);
expect(gsc.urgencyFor('vitals')).toBe(1);
expect(gsc.urgencyFor('weather')).toBe(1);
});
/* And under the status row, which is the one line that says whether any of this is current. */
it('never outranks the Pulse status row', () => {
const top = gsc.pulseRow({ app: 'my-app.engineer', read: 1, filed: {}, held: {} }).urgency;
for (const r of gsc.feedRowsFor(findings, { property: PROPERTY })) expect(r.urgency).toBeLessThan(top);
});
it('says so plainly when a row had nothing beside it', () => {
const row = gsc.feedRowsFor([{ kind: 'sitemap', label: 'none submitted', value: '' }], {})[0];
expect(row.fields.value).toBe('(nothing shown)');
});
});
describe('is Pulse up to date — the one row that says whether the rest is current', () => {
const now = new Date('2026-09-21T18:00:00Z');
const ok = { ok: true, wired: true, filed: 6, skipped: 0 };
it('says only today is up to date', () => {
expect(gsc.upToDate('2026-09-21', now)).toBe(true);
expect(gsc.upToDate('2026-09-20', now)).toBe(false);
expect(gsc.upToDate(null, now)).toBe(false);
});
it('counts the days, so "behind" is a number a person can act on', () => {
expect(gsc.daysBehind('2026-09-10', now)).toBe(11);
expect(gsc.daysBehind('2026-09-21', now)).toBe(0);
expect(gsc.daysBehind(null, now)).toBe(null);
expect(gsc.daysBehind('not a day', now)).toBe(null);
});
it('reports up to date when Pulse itself holds today', () => {
const row = gsc.pulseRow({ app: 'my-app.engineer', read: 6, filed: ok, held: { ok: true, wired: true, latestDay: '2026-09-21' }, now });
expect(row.fields.pulse).toBe('up to date');
expect(row.fields['pulse holds']).toContain('(today)');
expect(row.fields['filed now']).toBe('6 findings');
});
/* The eleven silent days. Pulse held the 10th and the screen read as if it were current. */
it('names the age when Pulse is behind', () => {
const row = gsc.pulseRow({ app: 'my-app.engineer', read: 0, filed: { ok: false, wired: true, filed: 0, skipped: 0, why: 'nothing worth filing' }, held: { ok: true, wired: true, latestDay: '2026-09-10' }, now });
expect(row.fields.pulse).toBe('behind');
expect(row.fields['pulse holds']).toBe('2026-09-10 — 11 days old');
expect(row.fields.why).toBe('nothing worth filing');
});
it('trusts what Pulse gave back, not our own write', () => {
const row = gsc.pulseRow({ app: 'a', read: 6, filed: { ok: true, wired: true, filed: 6 }, held: { ok: true, wired: true, latestDay: '2026-09-10' }, now });
expect(row.fields.pulse).toBe('behind');
});
it('distinguishes never connected from could not read', () => {
const off = gsc.pulseRow({ app: 'a', read: 6, filed: { ok: false, wired: false, why: 'Pulse is not connected to this browser' }, held: { ok: false, wired: false, findings: [] }, now });
expect(off.fields.pulse).toBe('Pulse is not connected');
const unread = gsc.pulseRow({ app: 'a', read: 6, filed: { ok: true, wired: true, filed: 6 }, held: { ok: false, wired: true, why: 'could not reach Pulse: ENOTFOUND' }, now });
expect(unread.fields.pulse).toBe('could not read Pulse back');
expect(unread.fields.why).toMatch(/could not reach Pulse/);
});
it('says when no app has been named, instead of blaming Pulse', () => {
const row = gsc.pulseRow({ app: '', read: 6, filed: {}, held: {}, now });
expect(row.fields.pulse).toBe('no app named');
expect(row.fields.app).toBeUndefined();
});
it('reports a pass that read nothing as exactly that', () => {
const row = gsc.pulseRow({ app: 'a', read: 0, filed: { ok: false, wired: true, filed: 0, skipped: 0 }, held: { ok: true, wired: true, latestDay: null, findings: [] }, now });
expect(row.fields['read this pass']).toMatch(/nothing read/);
expect(row.fields.pulse).toBe('nothing filed yet');
});
it('counts what Pulse would not store, so "filed 5" is never mistaken for "read 6"', () => {
const row = gsc.pulseRow({ app: 'a', read: 6, filed: { ok: true, wired: true, filed: 5, skipped: 1 }, held: { ok: true, wired: true, latestDay: '2026-09-21' }, now });
expect(row.fields['not filed']).toMatch(/1 had no name/);
});
/* Its title is its identity in the feed: one status row for ever, not one per pass. */
it('keeps one stable title whatever it says', () => {
const a = gsc.pulseRow({ app: 'my-app.engineer', read: 6, filed: ok, held: { ok: true, wired: true, latestDay: '2026-09-21' }, now });
const b = gsc.pulseRow({ app: 'my-app.engineer', read: 0, filed: {}, held: {}, now: new Date('2026-10-02T00:00:00Z') });
expect(a.title).toBe(gsc.PULSE_TITLE);
expect(b.title).toBe(a.title);
expect(a.url).toBe(b.url);
for (const k of Object.keys(a.fields)) expect(a.title).not.toContain(String(a.fields[k]));
});
it('sits above the findings and offers nothing to press', () => {
const row = gsc.pulseRow({ app: 'a', read: 6, filed: ok, held: { ok: true, wired: true, latestDay: '2026-09-21' }, now });
expect(row.urgency).toBe(9);
expect(row.draft).toBe('');
expect(row.readOnly).toBe(true);
});
});
/*
* WIRED AT EVERY DOOR. A watcher fires three ways — by hand from the results page, from the operator's
* runWatcher tool, and unattended from the scheduler — and a mode present at one door but absent from
* another does not error: it falls through to the plain role path, which runs the walk and then files
* and shows nothing. The failure looks exactly like success from the outside.
*/
describe('the gsc mode is reachable however the watcher is fired', () => {
const src = readFileSync(fileURLToPath(new URL('../src/server.js', import.meta.url)), 'utf8');
it('exists as its own pass', () => {
expect(src).toMatch(/async function gscWatchTick\(wf, owner, opts\) \{/);
});
it('is dispatched at all three doors', () => {
expect((src.match(/=== 'gsc'/g) || []).length).toBe(3);
expect((src.match(/gscWatchTick\(/g) || []).length).toBe(4); // the declaration plus three doors
});
it('files through the browser\'s own Pulse client, with no courier', () => {
const fn = src.slice(src.indexOf('async function gscWatchTick'));
expect(fn).toMatch(/require\('\.\/pulse'\)/);
expect(fn).toMatch(/recordGscHealth\(app, findings\)/);
/* Read back, never assumed: our own 200 is not evidence the row is in Pulse. */
/* And the read-back happens BEFORE the status row is built, or the row would report our write. */
expect(fn.slice(0, fn.indexOf('pulseRow('))).toMatch(/gscHealth\(app\)/);
});
it('records the pass, so the watcher card does not read as never run', () => {
const fn = src.slice(src.indexOf('async function gscWatchTick'), src.indexOf('async function gscWatchTick') + 8000);
expect(fn).toMatch(/recordRolePass\(wf, startedAt, run\)/);
});
/* The scheduler stubs a run for the modes that do not drive a flow. This one drives, so a stub here
would make scheduleDue() count one pass twice. */
it('does not stub a run in the scheduler, because drive() persists its own', () => {
const at = src.indexOf("=== 'gsc') {", src.indexOf('[workflow-sched] firing'));
expect(at).toBeGreaterThan(-1);
const branch = src.slice(at, at + 400);
expect(branch).not.toMatch(/persistRun/);
expect(branch).toMatch(/gscWatchTick\(wf, owner\)/);
});
});
/*
* ONCE A DAY, WHATEVER THE INTERVAL SAYS.
*
* The watcher editor on the phone and the desktop offers minute intervals, because it was written for
* notification watchers where every five minutes is the point. Saving a Search Console watcher from
* that form -- to rename it, say -- rewrites `every: 'day'` into `every: 'minute'`, and nothing
* downstream objects: the console then gets walked every few minutes by a model, on a signed-in
* account, for numbers that move once a day. The floor lives in the pass, where no edit reaches it.
*/
describe('how often it may read Google', () => {
const now = Date.parse('2026-09-21T18:00:00Z');
const hoursAgo = (h) => ({ lastPass: { startedAt: now - h * 3600 * 1000 } });
it('reads when it has never read', () => {
expect(gsc.duePass({}, now).due).toBe(true);
expect(gsc.duePass({ lastPass: {} }, now).due).toBe(true);
});
it('waits out the day even when the interval says minutes', () => {
const r = gsc.duePass(hoursAgo(1), now);
expect(r.due).toBe(false);
expect(r.why).toMatch(/next read in \d+ min/);
});
it('reads again once the day has passed', () => {
expect(gsc.duePass(hoursAgo(19), now).due).toBe(false);
expect(gsc.duePass(hoursAgo(21), now).due).toBe(true);
});
/* Pressing Run IS the reason to cross it: somebody is asking for a reading now. */
it('goes now when a person asked', () => {
expect(gsc.duePass(hoursAgo(1), now, { force: true }).due).toBe(true);
});
it('lets the gap be set as data, without a deploy', () => {
expect(gsc.duePass({ ...hoursAgo(2), minGapMs: 3600 * 1000 }, now).due).toBe(true);
expect(gsc.MIN_GAP_MS).toBe(20 * 3600 * 1000);
});
it('is what the pass actually checks, before it spends anything', () => {
const src = readFileSync(fileURLToPath(new URL('../src/server.js', import.meta.url)), 'utf8');
const fn = src.slice(src.indexOf('async function gscWatchTick'));
const guard = fn.indexOf('gscWatch.duePass(cfg');
expect(guard).toBeGreaterThan(-1);
/* Before the flow is driven, or it has already cost the walk it was meant to avoid. */
expect(guard).toBeLessThan(fn.indexOf('workflows.drive('));
});
});
/*
* REPOINTING THE PROPERTY ORPHANS EVERY ROW THE OLD ONE MADE.
*
* my-app.engineer turned out to be a DOMAIN property (sc-domain:my-app.engineer, verified by a DNS TXT
* record); the URL-prefix form https://my-app.engineer/ was never verified, because the site serves no
* verification meta tag. Pointed at the wrong form the console answered "Je hebt geen toegang tot deze
* property" six times and the walk filed that, correctly, as what it saw. A row is keyed on the tab it
* came from and that tab carries the property, so the new pass creates new rows and cannot reach the
* old ones: the page would show the true state of the property beside six rows denying we have access.
*/
describe('rows left behind by a property change', () => {
const OLD = 'https://my-app.engineer/';
const NEW = 'sc-domain:my-app.engineer';
const rowsFor = (prop) => gsc.feedRowsFor([
{ kind: 'indexing', label: 'property access', value: 'Je hebt geen toegang tot deze property' },
{ kind: 'sitemap', label: 'property access', value: 'Je hebt geen toegang tot deze property' },
], { property: prop }).map((r, i) => ({ ...r, key: 'k' + i, handled: false }));
it('retires what the current property cannot be reading', () => {
const stale = gsc.staleRows(rowsFor(OLD), NEW);
expect(stale).toHaveLength(2);
expect(stale.every((r) => r.url.includes(encodeURIComponent(OLD)))).toBe(true);
});
it("leaves this property’s own rows alone", () => {
expect(gsc.staleRows(rowsFor(NEW), NEW)).toEqual([]);
});
/* The status row has no url at all, and it is the one row that must never be retired. */
it('never touches the status row', () => {
const status = { ...gsc.pulseRow({ app: 'my-app.engineer', read: 1, filed: {}, held: {} }), key: 'p', handled: false };
expect(gsc.staleRows([status], NEW)).toEqual([]);
});
it('leaves anything that is not a console tab alone', () => {
const other = { key: 'x', url: 'https://useme.com/pl/jobs/foo,12345/', title: 'a gig', handled: false };
expect(gsc.staleRows([other], NEW)).toEqual([]);
});
it('does not re-retire what is already handled', () => {
const done = rowsFor(OLD).map((r) => ({ ...r, handled: true }));
expect(gsc.staleRows(done, NEW)).toEqual([]);
});
it("is what the pass does, before it writes today’s rows", () => {
const src = readFileSync(fileURLToPath(new URL('../src/server.js', import.meta.url)), 'utf8');
const fn = src.slice(src.indexOf('async function gscWatchTick'));
const retire = fn.indexOf('gscWatch.staleRows(');
expect(retire).toBeGreaterThan(-1);
expect(retire).toBeLessThan(fn.indexOf('gscWatch.feedRowsFor('));
expect(fn.slice(retire - 300, retire + 300)).toMatch(/markHandled/);
});
});
/*
* A FINDING THE CONSOLE NO LONGER SHOWS.
*
* "not indexed" moving from 64 to 61 is one row updated, which the key already handles. The case with
* no answer until now is a finding that goes AWAY: fix the noindex tag on the one page that had it and
* "Uitgesloten door tag noindex = 1" is simply absent from the next read — not zero, absent. Left
* alone it sits on the results page for ever, saying 1, being wrong, and looking exactly like the rows
* around it that are right.
*
* The guard matters more than the rule: a tab that would not open produces nothing, and reading that
* as "everything on this tab is gone" would wipe the last good reading of it.
*/
describe('findings the next pass no longer sees', () => {
const PROPERTY = 'sc-domain:my-app.engineer';
const feedRow = (r, i) => ({ ...r, key: 'k' + i, handled: false });
const yesterday = gsc.feedRowsFor([
{ kind: 'indexing', label: 'not indexed', value: '64' },
{ kind: 'indexing', label: 'Uitgesloten door tag noindex', value: '1' },
{ kind: 'sitemap', label: 'sitemap.xml', value: 'Succesvol' },
], { property: PROPERTY }).map(feedRow);
it('retires the one that is gone and keeps the one that moved', () => {
const today = gsc.feedRowsFor([{ kind: 'indexing', label: 'not indexed', value: '61' }], { property: PROPERTY });
const gone = gsc.supersededRows(yesterday, today);
expect(gone.map((r) => r.title)).toEqual(['Uitgesloten door tag noindex']);
});
/* The whole reason for the guard: a tab that produced nothing this pass was not read. */
it('leaves every row on a tab this pass did not read', () => {
const today = gsc.feedRowsFor([{ kind: 'indexing', label: 'not indexed', value: '61' }], { property: PROPERTY });
const gone = gsc.supersededRows(yesterday, today);
expect(gone.map((r) => r.title)).not.toContain('sitemap.xml');
});
it('retires nothing at all after a pass that read nothing', () => {
expect(gsc.supersededRows(yesterday, [])).toEqual([]);
expect(gsc.supersededRows(yesterday, null)).toEqual([]);
});
it('never touches the status row, which no tab produces', () => {
const status = { ...gsc.pulseRow({ app: 'my-app.engineer', read: 1, filed: {}, held: {} }), key: 'p', handled: false };
const today = gsc.feedRowsFor([{ kind: 'indexing', label: 'not indexed', value: '61' }], { property: PROPERTY });
expect(gsc.supersededRows([status, ...yesterday], today).map((r) => r.title)).not.toContain(gsc.PULSE_TITLE);
});
it('does not re-retire what is already handled', () => {
const done = yesterday.map((r) => ({ ...r, handled: true }));
const today = gsc.feedRowsFor([{ kind: 'indexing', label: 'not indexed', value: '61' }], { property: PROPERTY });
expect(gsc.supersededRows(done, today)).toEqual([]);
});
/* Matching is case- and space-insensitive on the title, because the label is the console's own text
and it has come back with different casing between reads. */
it('treats the same label read again as the same row, whatever the casing', () => {
const today = gsc.feedRowsFor([
{ kind: 'indexing', label: 'NOT INDEXED ', value: '61' },
{ kind: 'indexing', label: 'Uitgesloten door tag noindex', value: '1' },
], { property: PROPERTY });
expect(gsc.supersededRows(yesterday, today)).toEqual([]);
});
it('is what the pass does, after it has written today’s rows', () => {
const src = readFileSync(fileURLToPath(new URL('../src/server.js', import.meta.url)), 'utf8');
const fn = src.slice(src.indexOf('async function gscWatchTick'));
const wrote = fn.indexOf('gscWatch.feedRowsFor(');
const retire = fn.indexOf('gscWatch.supersededRows(');
expect(retire).toBeGreaterThan(wrote);
});
});
/*
* AND THE PASS COUNTS WHAT IT WROTE.
*
* recordRolePass counts the feed rows whose lastSeen falls inside the pass. Called straight after the
* flow \u2014 where the role path calls it, because there the agent's own collect has already written them
* \u2014 it ran before this mode had written a single row, so a pass that read twenty findings reported
* 0 messages on the watcher card. Which reads exactly like a pass that read nothing.
*/
describe('the health line the watcher card shows', () => {
const src = readFileSync(fileURLToPath(new URL('../src/server.js', import.meta.url)), 'utf8');
const fn = src.slice(src.indexOf('async function gscWatchTick'), src.indexOf('async function gscWatchTick') + 6000);
it('is recorded after the rows exist, not before', () => {
const wrote = fn.indexOf('feed.upsert(wf.id, row)');
const counted = fn.indexOf('recordRolePass(wf, startedAt, run)');
expect(wrote).toBeGreaterThan(-1);
expect(counted).toBeGreaterThan(wrote);
});
it('and still measures from before the walk started', () => {
expect(fn).toMatch(/const startedAt = Date\.now\(\);/);
expect(fn.indexOf('const startedAt')).toBeLessThan(fn.indexOf('workflows.drive('));
});
});