Detect a revoked or stale app registration and leave the broken connected view
While a session exists, the app probes getAppStatus() on load and on focus/visibility, at most once a minute (a trigger inside the minute is deferred, not dropped), failing open on errors. installed:false or upgradeNeeded:true re-runs registerApp, whose consent redirect is the recovery; meanwhile the app view is replaced by a renewal screen, and an unrecoverable registration lands on the picker with a notice. bun run test:e2e-revocation proves both sides on the live stack: a current registration stays usable, a revoked one leaves the connected state and its writes no longer land.
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Revocation probe: drives the REAL local ActivityPods stack through the browser
|
||||
* and asserts that Festipod notices when its registration on the user's pod is
|
||||
* revoked, instead of staying in a "connected" view whose writes cannot land.
|
||||
*
|
||||
* THREE PHASES, one fresh user:
|
||||
*
|
||||
* 1. CURRENT — picker → pod login → consent → app. The app-status probe runs on
|
||||
* load (the GET of the pod's `/.well-known/app-status` is observed on the
|
||||
* wire, with its body, and the app logs `[AppStatus] probe (load) → current`);
|
||||
* a focus + visibilitychange right after does NOT probe at once (one-minute
|
||||
* throttle); an event is created and read back from the pod; a
|
||||
* visibilitychange inside the minute is deferred, not dropped — its probe
|
||||
* lands at the end of the minute and reads current.
|
||||
* 2. REVOKED — the registration is removed on the pod
|
||||
* (`POST {pod}/.auth-agent/app-registrations/remove`, the user's own token).
|
||||
* (a) Without any reload, a window focus inside the minute — the user coming
|
||||
* back from the pod's tab — must, by the end of that minute, read the
|
||||
* revocation and take the app off the connected view: the consent screen
|
||||
* (the provider's `registerApp` redirect) or the picker. (b) Reloading the
|
||||
* app on the event form must do the same through the load probe, and the
|
||||
* event-creation drive that worked in phase 1 must not reach success: no
|
||||
* "Événement relayé", no event page, and nothing with that title on the pod.
|
||||
* 3. RECOVERED — accepting the consent screen brings the user back to a
|
||||
* connected app whose probe reads current again, and a write lands.
|
||||
*
|
||||
* Nothing is stubbed: the probe result is whatever the pod answers. The probe
|
||||
* is triggered only through the app's real paths (page load, window focus,
|
||||
* visibilitychange); its one-minute throttle is untouched.
|
||||
*
|
||||
* ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for
|
||||
* `http://localhost:4000/login` only, so the app MUST be served at APP_URL.
|
||||
*
|
||||
* Exit code 0 only when every phase holds; the JSON report says which did not.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import {
|
||||
linkId,
|
||||
podLogin,
|
||||
readResource,
|
||||
recordUri,
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
} from '../src/shared/support/activitypodsStack';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
const APP: string = strip(process.env.APP_URL || 'http://localhost:4000');
|
||||
const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||||
|
||||
const USER = `revoke${Date.now()}`;
|
||||
const PASS = 'Test1test';
|
||||
const DEADLINE = Date.now() + 10 * 60 * 1000;
|
||||
|
||||
type Phase = 'current' | 'revoked' | 'recovered';
|
||||
let phase: Phase = 'current';
|
||||
|
||||
interface StatusSeen { phase: Phase; status: number; body: unknown }
|
||||
interface HttpError { phase: Phase; url: string; status: number; method: string; body: string }
|
||||
|
||||
const statusSeen: StatusSeen[] = [];
|
||||
const probeLogs: Array<{ phase: Phase; at: number; text: string }> = [];
|
||||
const errors4xx: HttpError[] = [];
|
||||
const writes: Array<{ phase: Phase; method: string; url: string; status: number }> = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const redirects: string[] = [];
|
||||
const hops: string[] = [];
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
const snap = (page: Page, name: string): Promise<void> =>
|
||||
page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined);
|
||||
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function wire(page: Page): void {
|
||||
let last = '';
|
||||
page.on('framenavigated', f => {
|
||||
if (f === page.mainFrame() && f.url() !== last) {
|
||||
redirects.push(`[${phase}] ${clean(last || '(start)')} -> ${clean(f.url())}`);
|
||||
last = f.url();
|
||||
}
|
||||
});
|
||||
page.on('response', r => {
|
||||
const method = r.request()?.method?.() ?? '?';
|
||||
const url = r.url();
|
||||
if (method === 'GET' && url.endsWith('/.well-known/app-status')) {
|
||||
const at = phase;
|
||||
r.json()
|
||||
.then(body => statusSeen.push({ phase: at, status: r.status(), body }))
|
||||
.catch(() => statusSeen.push({ phase: at, status: r.status(), body: '(unreadable)' }));
|
||||
}
|
||||
if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && url.startsWith(config.podProviderUrl)) {
|
||||
writes.push({ phase, method, url: clean(url), status: r.status() });
|
||||
}
|
||||
if (r.status() >= 400) {
|
||||
const at = phase;
|
||||
r.text()
|
||||
.then(b => errors4xx.push({ phase: at, url, status: r.status(), method, body: b.slice(0, 300) }))
|
||||
.catch(() => errors4xx.push({ phase: at, url, status: r.status(), method, body: '(unreadable)' }));
|
||||
}
|
||||
});
|
||||
page.on('console', m => {
|
||||
const text = m.text();
|
||||
if (text.includes('[AppStatus]')) probeLogs.push({ phase, at: Date.now(), text: text.slice(0, 400) });
|
||||
if (m.type() === 'error') consoleErrors.push(`[${phase}] ${text.slice(0, 300)}`);
|
||||
});
|
||||
page.on('pageerror', e => consoleErrors.push(`[${phase}] pageerror: ${String(e.message).slice(0, 300)}`));
|
||||
}
|
||||
|
||||
const onApp = (page: Page): boolean => page.url().startsWith(APP);
|
||||
const pickerShown = async (page: Page): Promise<boolean> =>
|
||||
(await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0)) > 0;
|
||||
const whereIs = async (page: Page): Promise<string> =>
|
||||
!onApp(page)
|
||||
? (page.url().startsWith(`${POD_FRONT}/authorize`) ? 'consent' : `elsewhere:${clean(page.url())}`)
|
||||
: (await pickerShown(page)) ? 'picker' : 'still-connected';
|
||||
|
||||
/** Drive pod login → consent until the connected app shell is reached. */
|
||||
async function driveToApp(page: Page): Promise<boolean> {
|
||||
let authorizeSeen = 0;
|
||||
for (let i = 0; i < 25 && Date.now() < DEADLINE; i++) {
|
||||
const url = page.url();
|
||||
if (hops[hops.length - 1] !== `[${phase}] ${clean(url)}`) hops.push(`[${phase}] ${clean(url)}`);
|
||||
|
||||
if (url.startsWith(POD_FRONT) && (await page.locator('input[name="username"]').count().catch(() => 0))) {
|
||||
hops.push(` [${phase}] [login] submitting credentials`);
|
||||
await page.locator('input[name="username"]').fill(USER).catch(() => {});
|
||||
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
|
||||
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
if (await btn.count()) await btn.first().click().catch(() => {});
|
||||
else await page.locator('button[type=submit]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(3500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.startsWith(`${POD_FRONT}/authorize`)) {
|
||||
authorizeSeen++;
|
||||
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
const visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
hops.push(` [${phase}] [authorize #${authorizeSeen}] accept visible=${visible}`);
|
||||
await snap(page, `revocation-${phase}-consent`);
|
||||
if (visible) await acc.first().click().catch(() => {});
|
||||
if (authorizeSeen >= 3) { hops.push(` [${phase}] [LOOP] authorize revisited 3x`); return false; }
|
||||
await page.waitForTimeout(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (onApp(page)) {
|
||||
if (url.includes('/login')) { await page.waitForTimeout(3000); continue; }
|
||||
if (!(await pickerShown(page)) && !(await page.locator('#auth-reauthorizing').count().catch(() => 0))) {
|
||||
hops.push(` [${phase}] [app] shell reached`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The event-creation drive from `e2e-login-flow.ts`, reporting what the app showed. */
|
||||
async function createEvent(page: Page, title: string): Promise<{ eventUri?: string; successToast: boolean; finalUrl: string }> {
|
||||
let successToast = false;
|
||||
const toastWatch = setInterval(() => {
|
||||
void page.locator('text=Événement relayé').count().then(n => { if (n) successToast = true; }).catch(() => {});
|
||||
}, 250);
|
||||
try {
|
||||
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 3000 }).catch(() => {});
|
||||
const dates = page.locator('input[type="date"]');
|
||||
if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {});
|
||||
await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
const next = page.getByRole('button', { name: /Suivant/ });
|
||||
if (await next.count().catch(() => 0)) await next.click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(5000);
|
||||
} finally {
|
||||
clearInterval(toastWatch);
|
||||
}
|
||||
const finalUrl = page.url();
|
||||
const m = finalUrl.startsWith(`${APP}/events/`) ? /\/events\/([^/?#]+)/.exec(finalUrl.slice(APP.length)) : null;
|
||||
const segment = m?.[1];
|
||||
const eventUri = segment && segment !== 'new' ? decodeURIComponent(segment) : undefined;
|
||||
return { eventUri, successToast, finalUrl };
|
||||
}
|
||||
|
||||
/** Does the user's pod hold an event with this title? Read with the user's own token. */
|
||||
async function podHoldsEventTitled(session: ApodsSession, title: string): Promise<boolean> {
|
||||
const container = await readResource(session, `${session.storage}/as/event`).catch(() => undefined);
|
||||
if (!container) return false;
|
||||
if (JSON.stringify(container).includes(title)) return true;
|
||||
const raw = container['ldp:contains'] ?? container['contains'];
|
||||
const members = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(linkId).filter((u): u is string => !!u);
|
||||
for (const uri of members) {
|
||||
const rec = await readResource(session, uri).catch(() => undefined);
|
||||
if (rec && JSON.stringify(rec).includes(title)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Wait until a predicate holds or the bound elapses. */
|
||||
async function waitFor(pred: () => boolean | Promise<boolean>, ms: number): Promise<boolean> {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (await pred()) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return pred();
|
||||
}
|
||||
|
||||
/** Fire the real DOM event a probe listener is attached to. */
|
||||
const fire = (page: Page, trigger: 'focus' | 'visibility'): Promise<void> =>
|
||||
page.evaluate((which) => {
|
||||
if (which === 'focus') window.dispatchEvent(new Event('focus'));
|
||||
else document.dispatchEvent(new Event('visibilitychange'));
|
||||
}, trigger).catch(() => {});
|
||||
|
||||
const lastProbeAt = (): number =>
|
||||
Math.max(0, ...probeLogs.filter(l => l.text.includes('probe (')).map(l => l.at));
|
||||
|
||||
/**
|
||||
* The probe's own schedule, exercised live and never shortened: fire `trigger`
|
||||
* less than a minute after the app's last probe, check that NO probe starts at
|
||||
* once (throttle), then wait for the deferred probe of that trigger to land
|
||||
* with the expected verdict at the end of the minute (not dropped).
|
||||
*/
|
||||
async function deferredProbe(page: Page, trigger: 'focus' | 'visibility', verdict: RegExp): Promise<{
|
||||
firedAfterLastProbeMs: number; immediate: boolean; probed: boolean; gapFromLastProbeMs?: number;
|
||||
}> {
|
||||
const last = lastProbeAt();
|
||||
const firedAfterLastProbeMs = Date.now() - last;
|
||||
const before = probeLogs.length;
|
||||
await fire(page, trigger);
|
||||
await page.waitForTimeout(3000);
|
||||
const immediate = probeLogs.slice(before).some(l => l.text.includes('probe ('));
|
||||
const pattern = new RegExp(`probe \\(${trigger}\\) → ${verdict.source}`);
|
||||
const probed = await waitFor(() => probeLogs.slice(before).some(l => pattern.test(l.text)), 75_000);
|
||||
const hit = probeLogs.slice(before).find(l => pattern.test(l.text));
|
||||
return { firedAfterLastProbeMs, immediate, probed, gapFromLastProbeMs: hit ? hit.at - last : undefined };
|
||||
}
|
||||
|
||||
const isCurrent = (b: unknown): boolean =>
|
||||
typeof b === 'object' && b !== null && (b as Record<string, unknown>)['installed'] === true && (b as Record<string, unknown>)['upgradeNeeded'] === false;
|
||||
|
||||
/** Benign ≥400 by design (same classes as e2e-login-flow.ts). */
|
||||
function benign(e: HttpError): boolean {
|
||||
if (e.url.includes('/.notifications/WebSocketChannel2023')) return true;
|
||||
if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const user = await podLogin(config, USER, PASS);
|
||||
const appDescription = await readResource(undefined, `${BACKEND}/app`);
|
||||
const appUri = recordUri(appDescription);
|
||||
if (!appUri) throw new Error('GET /app carries no id (the app actor URI)');
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
wire(page);
|
||||
|
||||
// ── Phase 1: CURRENT ────────────────────────────────────────────────────
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
|
||||
const connected = await driveToApp(page);
|
||||
const probeRanCurrent = connected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'current' && s.status === 200 && isCurrent(s.body))
|
||||
&& probeLogs.some(l => l.phase === 'current' && /probe \(load\) → current/.test(l.text)),
|
||||
15000,
|
||||
);
|
||||
// Throttle, observed through the real listeners: focus + visibilitychange
|
||||
// seconds after the load probe must not issue another status request.
|
||||
const beforeNudge = statusSeen.filter(s => s.phase === 'current').length;
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}).catch(() => {});
|
||||
await page.waitForTimeout(3000);
|
||||
const afterNudge = statusSeen.filter(s => s.phase === 'current').length;
|
||||
const throttled = beforeNudge >= 1 && afterNudge === beforeNudge;
|
||||
const titleA = `Concert courant ${USER}`;
|
||||
const writeA = connected ? await createEvent(page, titleA) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
|
||||
const writeAOnPod = writeA.eventUri ? await podHoldsEventTitled(user, titleA) : false;
|
||||
await snap(page, 'revocation-current-after-write');
|
||||
// A visibilitychange right after the write's reload: deferred, then current.
|
||||
const visibilityProbe = connected ? await deferredProbe(page, 'visibility', /current/) : undefined;
|
||||
|
||||
// ── Phase 2: REVOKED ────────────────────────────────────────────────────
|
||||
phase = 'revoked';
|
||||
const revoke = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${user.token}` },
|
||||
body: JSON.stringify({ appUri }),
|
||||
});
|
||||
const revokeResult = { status: revoke.status, body: (await revoke.text()).slice(0, 300) };
|
||||
// Removal is processed by the pod and the app backend; give it a moment to settle.
|
||||
await sleep(3000);
|
||||
|
||||
// (a) FOCUS, no reload — the user revokes in the pod's tab and comes back
|
||||
// within the minute of the last probe: the deferred focus probe must read the
|
||||
// revocation and take the app off the connected view.
|
||||
const focusProbe = await deferredProbe(page, 'focus', /stale \(not-installed\)/);
|
||||
const leftByFocus = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
|
||||
const leftByFocusTo = await whereIs(page);
|
||||
await snap(page, 'revocation-revoked-left-by-focus');
|
||||
|
||||
// (b) RELOAD on the write path — the same event-creation drive that worked in
|
||||
// phase 1: the load probe must take the app off again, and nothing may
|
||||
// pretend the write succeeded.
|
||||
const titleB = `Concert revoque ${USER}`;
|
||||
const loadLogsBefore = probeLogs.length;
|
||||
const writeB = await createEvent(page, titleB);
|
||||
await snap(page, 'revocation-revoked-after-write-attempt');
|
||||
const leftAfterReload = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
|
||||
const leftAfterReloadTo = await whereIs(page);
|
||||
const loadProbeSawRevoked = probeLogs.slice(loadLogsBefore).some(l => /probe \(load\) → stale \(not-installed\)/.test(l.text));
|
||||
const writeBOnPod = await podHoldsEventTitled(user, titleB);
|
||||
await snap(page, 'revocation-revoked-left');
|
||||
|
||||
// ── Phase 3: RECOVERED ──────────────────────────────────────────────────
|
||||
phase = 'recovered';
|
||||
const reconnected = leftAfterReloadTo === 'consent' ? await driveToApp(page) : false;
|
||||
const probeReadsCurrentAgain = reconnected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'recovered' && s.status === 200 && isCurrent(s.body)),
|
||||
15000,
|
||||
);
|
||||
const titleC = `Concert retabli ${USER}`;
|
||||
const writeC = reconnected ? await createEvent(page, titleC) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
|
||||
const writeCOnPod = writeC.eventUri ? await podHoldsEventTitled(user, titleC) : false;
|
||||
await snap(page, 'revocation-recovered-after-write');
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
await sleep(1000);
|
||||
|
||||
// The deferred probe lands at the end of the minute: never before (throttle),
|
||||
// and not much after (it was deferred, not dropped). Measured between the
|
||||
// app's own log lines, so a few hundred ms of request latency either way.
|
||||
const deferredOnTime = (p?: { immediate: boolean; probed: boolean; gapFromLastProbeMs?: number }): boolean =>
|
||||
!!p && !p.immediate && p.probed && p.gapFromLastProbeMs !== undefined
|
||||
&& p.gapFromLastProbeMs >= 58_000 && p.gapFromLastProbeMs <= 75_000;
|
||||
const positive = {
|
||||
connected,
|
||||
probeRanCurrent,
|
||||
throttled,
|
||||
statusRequestsAfterNudge: afterNudge,
|
||||
write: { ...writeA, onPod: writeAOnPod },
|
||||
visibilityProbe,
|
||||
ok: connected && probeRanCurrent && throttled && !!writeA.eventUri && writeA.successToast && writeAOnPod
|
||||
&& deferredOnTime(visibilityProbe),
|
||||
};
|
||||
const negative = {
|
||||
revoke: revokeResult,
|
||||
focusProbe,
|
||||
leftByFocus,
|
||||
leftByFocusTo,
|
||||
loadProbeSawRevoked,
|
||||
leftAfterReload,
|
||||
leftAfterReloadTo,
|
||||
writeAttempt: { ...writeB, onPod: writeBOnPod },
|
||||
ok: (revoke.status === 200 || revoke.status === 204)
|
||||
&& deferredOnTime(focusProbe) && leftByFocus && (leftByFocusTo === 'consent' || leftByFocusTo === 'picker')
|
||||
&& loadProbeSawRevoked && leftAfterReload && (leftAfterReloadTo === 'consent' || leftAfterReloadTo === 'picker')
|
||||
&& !writeB.eventUri && !writeB.successToast && !writeBOnPod,
|
||||
};
|
||||
const recovered = {
|
||||
reconnected,
|
||||
probeReadsCurrentAgain,
|
||||
write: { ...writeC, onPod: writeCOnPod },
|
||||
ok: reconnected && probeReadsCurrentAgain && !!writeC.eventUri && writeC.successToast && writeCOnPod,
|
||||
};
|
||||
// Phase 2's ≥400s are the revocation's own consequences; the other phases must be clean.
|
||||
const fatal = errors4xx.filter(e => e.phase !== 'revoked' && !benign(e));
|
||||
const report = {
|
||||
user: USER,
|
||||
appUri,
|
||||
positive,
|
||||
negative,
|
||||
recovered,
|
||||
statusBodies: statusSeen,
|
||||
probeLogs,
|
||||
writes,
|
||||
revokedPhase4xx: errors4xx.filter(e => e.phase === 'revoked').map(e => `${e.status} ${e.method} ${clean(e.url)}`),
|
||||
fatal4xx: fatal,
|
||||
redirects,
|
||||
hops,
|
||||
consoleErrors,
|
||||
ok: positive.ok && negative.ok && recovered.ok && fatal.length === 0,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
})().catch(err => {
|
||||
console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), phase, statusSeen, probeLogs, redirects, errors4xx, consoleErrors }, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user