mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5971e120cb |
@@ -7,6 +7,7 @@ import {
|
||||
readDetectionConfig,
|
||||
readRawDetectionConfig,
|
||||
writeDetectionConfig,
|
||||
extractFindingIgnoreValue,
|
||||
} from '../../lib/impeccable-config.mjs';
|
||||
|
||||
const ACTION_ALIASES = new Map([
|
||||
@@ -235,6 +236,9 @@ function addFile(cwd, args) {
|
||||
function addValue(cwd, args) {
|
||||
const { local, rest } = parseScope(args);
|
||||
const parsed = parseValueArgs(rest);
|
||||
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
|
||||
throw new Error(`${parsed.rule} has no extractable ignore value. Use impeccable ignores add-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
|
||||
}
|
||||
const config = readScopeConfig(cwd, local);
|
||||
const key = ignoreValueKey(parsed);
|
||||
const existing = config.ignoreValues.find((entry) => ignoreValueKey(entry) === key);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ensureHookGitExcludes,
|
||||
normalizeIgnoreValue,
|
||||
normalizeIgnoreValueEntries,
|
||||
extractFindingIgnoreValue,
|
||||
} from './hook-lib.mjs';
|
||||
|
||||
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
|
||||
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
|
||||
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
|
||||
}
|
||||
|
||||
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
|
||||
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
|
||||
}
|
||||
|
||||
const local = parsed.local;
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
|
||||
// Key on the file scope too: the same rule/value legitimately appears more than
|
||||
|
||||
@@ -1019,9 +1019,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
// exits on any pick and has no update channel, so a followup payload there
|
||||
// still gets the goodbye screen, never a loading hand nothing will resolve.
|
||||
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
|
||||
const KEY = ${JSON.stringify(detachedKey || '')};
|
||||
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
|
||||
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
|
||||
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
|
||||
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
|
||||
const beatTimer = setInterval(beat, 5000);
|
||||
// A dead server must fail loudly: awaiting a rejected fetch here used to
|
||||
@@ -1032,7 +1030,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
// is in flight would overwrite the answer being collected.
|
||||
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
|
||||
try {
|
||||
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
|
||||
} catch {
|
||||
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
|
||||
return;
|
||||
@@ -1306,7 +1304,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
};
|
||||
const apply = (value) => {
|
||||
set(value);
|
||||
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
|
||||
if (value === 'comp') enterComp(); else exitComp();
|
||||
};
|
||||
// Flipping to comp starts real generation, so it confirms first; the
|
||||
@@ -1474,7 +1472,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
// re-roll and renewed the delivery deadline.
|
||||
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
|
||||
try {
|
||||
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
|
||||
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
|
||||
} catch {
|
||||
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
|
||||
return;
|
||||
@@ -1568,39 +1566,8 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
|
||||
</script>`;
|
||||
}
|
||||
|
||||
// Browsers omit the :80 suffix on the default HTTP port, so a server on
|
||||
// --port 80 sees bare loopback hosts and origins.
|
||||
function allowedHost(host, port) {
|
||||
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
|
||||
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
|
||||
}
|
||||
|
||||
function allowedOrigin(origin, port) {
|
||||
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
|
||||
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
|
||||
}
|
||||
|
||||
function rejectDetachedPost(req, res, url, port) {
|
||||
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
|
||||
res.writeHead(401); res.end(); return true;
|
||||
}
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !allowedOrigin(origin, port)) {
|
||||
res.writeHead(403); res.end(); return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const { port } = server.address();
|
||||
if (!allowedHost(req.headers.host, port)) {
|
||||
res.writeHead(403); res.end(); return;
|
||||
}
|
||||
let url;
|
||||
try { url = new URL(req.url, 'http://127.0.0.1'); }
|
||||
catch { res.writeHead(400); res.end(); return; }
|
||||
const pathname = url.pathname;
|
||||
if (req.method === 'GET' && pathname === '/') {
|
||||
if (req.method === 'GET' && req.url === '/') {
|
||||
const pending = nextFile();
|
||||
if (pending && fs.existsSync(pending)) {
|
||||
// A next file the round cannot load has to leave the disk either way:
|
||||
@@ -1626,8 +1593,7 @@ const server = http.createServer((req, res) => {
|
||||
res.end(page(awaitingNext));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/heartbeat') {
|
||||
if (rejectDetachedPost(req, res, url, port)) return;
|
||||
if (req.method === 'POST' && req.url === '/heartbeat') {
|
||||
res.writeHead(204); res.end();
|
||||
server.lastBeatSeen = Date.now();
|
||||
if (detachedKey) {
|
||||
@@ -1643,13 +1609,13 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/next-status') {
|
||||
if (req.method === 'GET' && req.url === '/next-status') {
|
||||
const pending = nextFile();
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
|
||||
return;
|
||||
}
|
||||
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
|
||||
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
|
||||
if (imageMatch) {
|
||||
const abs = localImages[Number(imageMatch[1])];
|
||||
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
|
||||
@@ -1662,8 +1628,7 @@ const server = http.createServer((req, res) => {
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/build-path') {
|
||||
if (rejectDetachedPost(req, res, url, port)) return;
|
||||
if (req.method === 'POST' && req.url === '/build-path') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
@@ -1683,8 +1648,7 @@ const server = http.createServer((req, res) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/answer') {
|
||||
if (rejectDetachedPost(req, res, url, port)) return;
|
||||
if (req.method === 'POST' && req.url === '/answer') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
|
||||
@@ -115,6 +115,13 @@ describe('impeccable ignores CLI', () => {
|
||||
expect(existsSync(join(root, '.impeccable', 'config.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects exact values for rules that cannot extract one', () => {
|
||||
const result = run(['add-value', 'side-tab', 'Inter']);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/side-tab has no extractable ignore value.*add-value side-tab "\*" --file <glob>/);
|
||||
expect(existsSync(join(root, '.impeccable', 'config.json'))).toBe(false);
|
||||
});
|
||||
|
||||
test('removes an existing broad wildcard value ignore', () => {
|
||||
mkdirSync(join(root, '.impeccable'), { recursive: true });
|
||||
writeFileSync(join(root, '.impeccable', 'config.json'), JSON.stringify({
|
||||
|
||||
@@ -819,6 +819,28 @@ describe('hook-admin.mjs', () => {
|
||||
assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config');
|
||||
});
|
||||
|
||||
it('ignore-value refuses exact values for rules that cannot extract one', () => {
|
||||
assert.throws(
|
||||
() => runAdmin(['ignore-value', 'cramped-padding', 'padding: 4px 8px']),
|
||||
/cramped-padding has no extractable ignore value.*ignore-value cramped-padding "\*" --file <glob>/,
|
||||
);
|
||||
assert.throws(
|
||||
() => runAdmin(['ignore-value', 'side-tab', 'Inter', '--file', 'a.css']),
|
||||
/side-tab has no extractable ignore value.*ignore-value side-tab "\*" --file <glob>/,
|
||||
);
|
||||
assert.equal(fs.existsSync(getConfigPath(cwd)), false, 'a refused ignore must not write config');
|
||||
|
||||
const out = runAdmin(['ignore-value', 'overused-font', 'Inter']);
|
||||
assert.match(out, /Added overused-font=inter/);
|
||||
|
||||
runAdmin(['ignore-value', 'cramped-padding', '*', '--file', 'index.html']);
|
||||
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).detector;
|
||||
assert.equal(shared.ignoreValues.filter((e) => e.rule === 'cramped-padding').length, 1);
|
||||
const entry = shared.ignoreValues.find((e) => e.rule === 'cramped-padding');
|
||||
assert.equal(entry.value, '*');
|
||||
assert.deepEqual(entry.files, ['index.html']);
|
||||
});
|
||||
|
||||
it('ignore-value --file requires a glob', () => {
|
||||
assert.throws(
|
||||
() => runAdmin(['ignore-value', 'side-tab', '*', '--file']),
|
||||
|
||||
@@ -843,7 +843,7 @@ describe('new-work-e2e: serve-question decision page', () => {
|
||||
// the server keeps its real clock, so its own idle grace never fires.
|
||||
await page.clock.install();
|
||||
let beats = 0;
|
||||
page.on('request', (r) => { if (new URL(r.url()).pathname === '/heartbeat') beats += 1; });
|
||||
page.on('request', (r) => { if (r.url().endsWith('/heartbeat')) beats += 1; });
|
||||
await page.goto(url, { waitUntil: 'load' });
|
||||
// Playwright actionability waits on rAF, which the fake clock owns, so
|
||||
// dispatch the click directly.
|
||||
|
||||
+11
-134
@@ -1,7 +1,6 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import { writeFileSync, readFileSync, rmSync, utimesSync, mkdtempSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -42,25 +41,6 @@ const PAYLOAD = {
|
||||
steer: true,
|
||||
};
|
||||
|
||||
function rawRequest(port, { method = 'GET', path: reqPath = '/', headers = {} } = {}, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
method,
|
||||
path: reqPath,
|
||||
headers,
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('serve-question', () => {
|
||||
it('opens Windows URLs through cmd.exe and reserves the start title argument', () => {
|
||||
assert.deepEqual(
|
||||
@@ -125,115 +105,12 @@ describe('serve-question', () => {
|
||||
assert.ok(url, started.out);
|
||||
const waiting = await run(['--wait', '--key', 'tk', '--poll', '1']);
|
||||
assert.equal(waiting.code, 3);
|
||||
await fetch(`${url}answer?key=tk`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'assigned', steer: '' }) });
|
||||
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'assigned', steer: '' }) });
|
||||
const collected = await run(['--wait', '--key', 'tk', '--poll', '5']);
|
||||
assert.equal(collected.code, 0);
|
||||
assert.match(collected.out, /"optionId":"assigned"/);
|
||||
});
|
||||
|
||||
it('rejects detached POSTs without the session key or with bad Host/Origin', async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
||||
const payloadPath = path.join(dir, 'q.json');
|
||||
const key = 'seckey';
|
||||
const answerPath = path.join(dir, '.impeccable', 'questions', `${key}.answer.json`);
|
||||
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
|
||||
const run = (args) => new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
let out = '';
|
||||
child.stdout.on('data', (chunk) => { out += chunk; });
|
||||
child.on('exit', (code) => resolve({ code, out }));
|
||||
});
|
||||
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', key]);
|
||||
assert.equal(started.code, 0);
|
||||
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
|
||||
assert.ok(url, started.out);
|
||||
const port = Number(new URL(url).port);
|
||||
const goodHost = `127.0.0.1:${port}`;
|
||||
const body = JSON.stringify({ optionId: 'assigned', steer: '' });
|
||||
const jsonHeaders = { 'content-type': 'application/json', Host: goodHost };
|
||||
|
||||
const noKey = await fetch(`http://${goodHost}/answer`, { method: 'POST', headers: jsonHeaders, body });
|
||||
assert.equal(noKey.status, 401);
|
||||
assert.equal(existsSync(answerPath), false);
|
||||
const waiting = await run(['--wait', '--key', key, '--poll', '1']);
|
||||
assert.equal(waiting.code, 3);
|
||||
|
||||
const wrongKey = await fetch(`http://${goodHost}/answer?key=wrong`, { method: 'POST', headers: jsonHeaders, body });
|
||||
assert.equal(wrongKey.status, 401);
|
||||
|
||||
const evilOrigin = await rawRequest(port, {
|
||||
method: 'POST',
|
||||
path: `/answer?key=${key}`,
|
||||
headers: { ...jsonHeaders, Origin: 'https://evil.example' },
|
||||
}, body);
|
||||
assert.equal(evilOrigin.status, 403);
|
||||
assert.equal(existsSync(answerPath), false);
|
||||
|
||||
const spoofedHostPost = await rawRequest(port, {
|
||||
method: 'POST',
|
||||
path: `/answer?key=${key}`,
|
||||
headers: { ...jsonHeaders, Host: `evil.example:${port}` },
|
||||
}, body);
|
||||
assert.equal(spoofedHostPost.status, 403);
|
||||
|
||||
const noKeyBeat = await fetch(`http://${goodHost}/heartbeat`, { method: 'POST', headers: { Host: goodHost } });
|
||||
assert.equal(noKeyBeat.status, 401);
|
||||
|
||||
const spoofedHostGet = await rawRequest(port, {
|
||||
path: '/',
|
||||
headers: { Host: `evil.example:${port}` },
|
||||
});
|
||||
assert.equal(spoofedHostGet.status, 403);
|
||||
|
||||
// The bare-host allowance exists only for --port 80, where browsers omit
|
||||
// the suffix; on any other port a portless Host stays rejected.
|
||||
const bareHostGet = await rawRequest(port, {
|
||||
path: '/',
|
||||
headers: { Host: '127.0.0.1' },
|
||||
});
|
||||
assert.equal(bareHostGet.status, 403);
|
||||
|
||||
const slashSlash = await rawRequest(port, {
|
||||
path: '//',
|
||||
headers: { Host: goodHost },
|
||||
});
|
||||
assert.equal(slashSlash.status, 400);
|
||||
assert.equal((await fetch(url)).status, 200);
|
||||
|
||||
// The build-path flip commands the agent through --wait, so it takes the
|
||||
// same key and Origin gate as /answer.
|
||||
const flipPath = path.join(dir, '.impeccable', 'questions', `${key}.flip.json`);
|
||||
const flipBody = JSON.stringify({ value: 'comp' });
|
||||
const noKeyFlip = await fetch(`http://${goodHost}/build-path`, { method: 'POST', headers: jsonHeaders, body: flipBody });
|
||||
assert.equal(noKeyFlip.status, 401);
|
||||
assert.equal(existsSync(flipPath), false);
|
||||
const evilOriginFlip = await rawRequest(port, {
|
||||
method: 'POST',
|
||||
path: `/build-path?key=${key}`,
|
||||
headers: { ...jsonHeaders, Origin: 'https://evil.example' },
|
||||
}, flipBody);
|
||||
assert.equal(evilOriginFlip.status, 403);
|
||||
assert.equal(existsSync(flipPath), false);
|
||||
const okFlip = await fetch(`http://${goodHost}/build-path?key=${key}`, { method: 'POST', headers: jsonHeaders, body: flipBody });
|
||||
assert.equal(okFlip.status, 200);
|
||||
assert.equal(existsSync(flipPath), true);
|
||||
const flipped = await run(['--wait', '--key', key, '--poll', '2']);
|
||||
assert.equal(flipped.code, 0);
|
||||
assert.match(flipped.out, /BUILD PATH FLIPPED/);
|
||||
|
||||
const html = await (await fetch(url)).text();
|
||||
assert.match(html, /const KEY = "seckey"/);
|
||||
assert.match(html, /\/answer' \+ keyQ/);
|
||||
assert.match(html, /\/heartbeat' \+ keyQ/);
|
||||
assert.match(html, /\/build-path' \+ keyQ/);
|
||||
|
||||
const ok = await fetch(`http://${goodHost}/answer?key=${key}`, { method: 'POST', headers: jsonHeaders, body });
|
||||
assert.equal(ok.status, 200);
|
||||
const collected = await run(['--wait', '--key', key, '--poll', '5']);
|
||||
assert.equal(collected.code, 0);
|
||||
assert.match(collected.out, /"optionId":"assigned"/);
|
||||
});
|
||||
|
||||
it('headless detection spares the modes that never open a browser', async () => {
|
||||
// Only the blocking serve path auto-opens a URL. --wait polls a daemon
|
||||
// that is already running, --stop kills one, --schema just prints text,
|
||||
@@ -334,7 +211,7 @@ describe('serve-question', () => {
|
||||
// Beat well past the 3s timeout: the timer must not fire under a live page.
|
||||
const beatUntil = Date.now() + 5500;
|
||||
while (Date.now() < beatUntil) {
|
||||
await fetch(`${url}heartbeat?key=life`, { method: 'POST' });
|
||||
await fetch(`${url}heartbeat`, { method: 'POST' });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
}
|
||||
const alive = await fetch(url);
|
||||
@@ -419,7 +296,7 @@ describe('serve-question', () => {
|
||||
// One beat, then silence: the idle grace must still reclaim the daemon.
|
||||
// Before the fix, the whole lifetime check sat inside timeoutSec > 0 and
|
||||
// a closed tab leaked this daemon forever.
|
||||
await fetch(`${url}heartbeat?key=zero`, { method: 'POST' });
|
||||
await fetch(`${url}heartbeat`, { method: 'POST' });
|
||||
const deadline = Date.now() + 12000;
|
||||
let gone = false;
|
||||
while (Date.now() < deadline && !gone) {
|
||||
@@ -444,8 +321,8 @@ describe('serve-question', () => {
|
||||
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
|
||||
assert.ok(url, started.out);
|
||||
try {
|
||||
await fetch(`${url}heartbeat?key=latehand`, { method: 'POST' });
|
||||
await fetch(`${url}answer?key=latehand`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
await fetch(`${url}heartbeat`, { method: 'POST' });
|
||||
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
// Go silent like a stalled page until just before the 3s idle
|
||||
// deadline, then deliver: the daemon used to exit before the page's
|
||||
// watch could claim the hand, orphaning a delivery --update had
|
||||
@@ -490,7 +367,7 @@ describe('serve-question', () => {
|
||||
// serving decision has to live here: once a re-roll answer is collected
|
||||
// and no replacement has landed, GET / re-enters the bounded shuffle
|
||||
// wait instead of re-serving the answered cards.
|
||||
await fetch(`${url}answer?key=refresh`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
const waitingPage = await (await fetch(url)).text();
|
||||
assert.ok(waitingPage.includes('awaitNextRound(false,'), 'a refresh mid re-roll re-enters the shuffle wait');
|
||||
const nextPath = path.join(dir, 'next.json');
|
||||
@@ -520,7 +397,7 @@ describe('serve-question', () => {
|
||||
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
|
||||
assert.ok(url, started.out);
|
||||
try {
|
||||
await fetch(`${url}answer?key=deadline`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
const fresh = await (await fetch(url)).text();
|
||||
const budget = Number(fresh.match(/awaitNextRound\(false, (\d+)\);/)?.[1]);
|
||||
assert.ok(budget > 0 && budget <= 3000, `the waiting page carries the remaining allowance, got ${budget}`);
|
||||
@@ -529,7 +406,7 @@ describe('serve-question', () => {
|
||||
// click-time disable can race a second click, so the server keeps the
|
||||
// first stamp instead of renewing the allowance.
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
await fetch(`${url}answer?key=deadline`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
const restamped = Number((await (await fetch(url)).text()).match(/awaitNextRound\(false, (\d+)\);/)?.[1]);
|
||||
assert.ok(restamped > 0 && restamped < 2500, `a duplicate re-roll does not renew the allowance, got ${restamped}`);
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
@@ -565,7 +442,7 @@ describe('serve-question', () => {
|
||||
// A bad file that reaches the disk anyway must not trap the page:
|
||||
// GET / discards it, so /next-status stops reporting a hand that can
|
||||
// never render and the bounded wait resumes instead of reload-looping.
|
||||
await fetch(`${url}answer?key=badhand`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
writeFileSync(path.join(dir, '.impeccable', 'questions', 'badhand.next.json'), JSON.stringify({ title: 'No options' }));
|
||||
const page = await (await fetch(url)).text();
|
||||
assert.ok(page.includes('awaitNextRound(false,'), 'the round stays in the wait');
|
||||
@@ -591,7 +468,7 @@ describe('serve-question', () => {
|
||||
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
|
||||
assert.ok(url, started.out);
|
||||
try {
|
||||
await fetch(`${url}answer?key=silent`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
const collected = await run(['--wait', '--key', 'silent', '--poll', '2']);
|
||||
assert.equal(collected.code, 0, collected.out);
|
||||
// The stalled page went silent by design: fake a beat older than the
|
||||
@@ -644,7 +521,7 @@ describe('serve-question', () => {
|
||||
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
|
||||
assert.ok(url, started.out);
|
||||
try {
|
||||
await fetch(`${url}answer?key=claimgap`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
|
||||
const collected = await run(['--wait', '--key', 'claimgap', '--poll', '2']);
|
||||
assert.equal(collected.code, 0, collected.out);
|
||||
const statePath = path.join(dir, '.impeccable', 'questions', 'claimgap.state.json');
|
||||
|
||||
Reference in New Issue
Block a user