A real `console fill apple-app-privacy` clicked Next onto a console page the engine did not recognise and logged nothing for 8 hours. - Step watchdog: after a click, or while the form is expected, no known page within --step-timeout seconds (default 30) -> "the page after "Next" is not a page storebound knows ... Buttons there: ...", a debug dump, and the manual steps. It keeps watching, but 20 min without progress closes the window and exits 1. - A heartbeat line every minute while waiting (also during sign-in); the final review wait is bounded (2 h) with a reminder every 10 min. - Debug dump (cli/console/debug.ts) in .storebound/console-debug/ <form>-<time>/: page.json (URL without query, headings, dialogs, every visible control with role and name), page.html (rebuilt from a clone: no scripts, values, hidden inputs or non-whitelisted attributes) and a small screenshot. Emails, phone numbers and the header / account area are replaced; cookies, storage and tokens are never read. --debug-dump writes one at every step for bug reports. - Guardrail tests: an unknown-page fixture (dump sanitized, bounded exit) and a scan of cli/console for while loops without a deadline and Playwright waits without a timeout (one waitForLoadState fixed). - Pitfall 14 (docs/pitfalls.md, store-pitfalls skill); the console-autofill skill says how to send a dump.
313 lines
20 KiB
TypeScript
313 lines
20 KiB
TypeScript
// storebound console fill walks a whole form: it clicks every navigation control (Next, App Privacy's Get Started,
|
|
// Edit next to Data Types, each data type) and App Privacy's in-dialog draft Save itself, stops only for an uncertain
|
|
// answer (until the owner set it), an agreement or the final button (Publish, Submit, Save, Create), which stays the
|
|
// owner's. Runs fillSession against MODELED pages (tests/fixtures/console) on 127.0.0.1. No network.
|
|
// Guardrail: no log line ever asks the owner to click a navigation control.
|
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fillSession } from '../cli/console/fill';
|
|
import type { ConsoleBrowser } from '../cli/console/session';
|
|
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy';
|
|
import type { ShipConfig } from '../cli/lib/config';
|
|
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS } from '../cli/lib/console-forms';
|
|
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, testScope } from './helpers/browser';
|
|
|
|
const FIX = join(import.meta.dir, 'fixtures', 'console');
|
|
const base0: ShipConfig = {
|
|
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
|
|
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
|
|
apple: { bundleId: 'com.example.game', primaryLocale: 'en-US' },
|
|
play: { packageName: 'com.example.game' },
|
|
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
|
|
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: false, data: { PSL_DEVICE_ID: { shared: true, collect: ['advertising', 'analytics'], share: ['advertising'] } } },
|
|
console: { contentRating: { answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'] } },
|
|
} as ShipConfig;
|
|
/** Every App Privacy answer certain: Device ID and Crash Data collected, nothing else. */
|
|
function privacy(over: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {}): ShipConfig {
|
|
const types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
|
|
types.DEVICE_ID = { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true };
|
|
types.CRASH_DATA = { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false };
|
|
return { ...base0, dataSafety: undefined, appPrivacy: { types: { ...types, ...over } } } as unknown as ShipConfig;
|
|
}
|
|
|
|
const shared = await launchTestBrowser('playwright');
|
|
const { browser, skipWhy } = shared;
|
|
if (!browser) console.warn(`console-flow browser tests skipped: no Chromium installed (${skipWhy}). Run \`bunx playwright install chromium\`.`);
|
|
const scope = testScope(() => browser);
|
|
const TEST_MS = 60_000;
|
|
|
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
let base = '';
|
|
beforeAll(() => {
|
|
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
|
|
const name = new URL(req.url).pathname.slice(1);
|
|
if (!/^[a-z_-]+\.(html|js)$/.test(name)) return new Response('not found', { status: 404 });
|
|
return new Response(Bun.file(join(FIX, name)));
|
|
} });
|
|
base = `http://127.0.0.1:${server.port}/`;
|
|
});
|
|
afterEach(() => scope.settle(), AFTER_EACH_MS);
|
|
afterAll(async () => { await shared.close(); server?.stop(true); }, AFTER_ALL_MS);
|
|
|
|
async function local(): Promise<ConsoleBrowser> {
|
|
const ctx = scope.track(await browser.newContext());
|
|
const page = await ctx.newPage();
|
|
let closed = false;
|
|
const done = new Promise<void>(r => ctx.on('close', () => { closed = true; r(); }));
|
|
return { mode: 'profile', context: ctx, page, pages: () => ctx.pages(), closed: done, isClosed: () => closed, close: () => ctx.close(), cleanup: () => {} };
|
|
}
|
|
const fast = (log: ReturnType<typeof logLines>) => ({ say: log.say, tickMs: 100, navTimeoutMs: 5000, signInWaitMs: 30_000, env: {} });
|
|
const clicks = (b: ConsoleBrowser): Promise<string[]> => b.page.evaluate(() => (window as any).__clicks);
|
|
const navClicks = (b: ConsoleBrowser): Promise<string[]> => b.page.evaluate(() => (window as any).__navClicks());
|
|
/** Guardrail: a line that tells the owner to click a navigation control themselves. */
|
|
const NAV_YOURSELF = /\b(Next|Continue|Get Started|Edit|Back|Set Up|Start|Manage|New App)\b[^.]*\byourself\b|\byourself\b[^.]*\b(Next|Continue|Get Started|Edit next|Set Up)\b/;
|
|
const navAsked = (lines: string[]) => lines.filter(l => NAV_YOURSELF.test(l));
|
|
const PUBLISH = 'review the summary, then press Publish yourself';
|
|
|
|
describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
|
|
const at = (w: Partial<FlowWhere> = {}): FlowWhere => ({ privacyPage: true, inDialog: false, inDataTypes: false, dataTypes: ['Device ID'], ...w });
|
|
test('App Privacy: openers, Next and the in-dialog Save are clicked; Publish never', () => {
|
|
expect(flowAllowed('apple-app-privacy', 'Get Started', 'button', at())).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Başlayın', 'button', at())).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Edit', 'button', at({ inDataTypes: true }))).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Edit', 'button', at())).toBe(false); // the Privacy Policy URL's Edit
|
|
expect(flowAllowed('apple-app-privacy', 'Set Up Device ID', 'button', at({ inDataTypes: true }))).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Device ID', 'link', at({ inDataTypes: true }))).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Next', 'button', at({ inDialog: true }))).toBe(true);
|
|
expect(flowClass('apple-app-privacy', 'Save', 'button', at({ inDialog: true }))).toBe('draft-save');
|
|
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at({ inDialog: true }))).toBe(true);
|
|
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at())).toBe(false); // not in a questionnaire dialog
|
|
expect(flowAllowed('apple-app-privacy', 'Save', 'button', at({ inDialog: true, privacyPage: false }))).toBe(false);
|
|
for (const n of ['Publish', 'Yayınla', 'Submit for Review']) expect(flowAllowed('apple-app-privacy', n, 'button', at({ inDialog: true, inDataTypes: true }))).toBe(false);
|
|
expect(flowAllowed('apple-app-privacy', 'Get Started', 'checkbox', at())).toBe(false);
|
|
});
|
|
test('other forms: Next is navigation; the content rating\'s Save draft is clicked; Save, Submit, Create, Done stay the owner\'s', () => {
|
|
const w = at({ privacyPage: false });
|
|
for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) {
|
|
expect(flowAllowed(f, 'Next', 'button', w)).toBe(true);
|
|
expect(flowAllowed(f, 'İleri', 'button', w)).toBe(true);
|
|
expect(flowAllowed(f, 'Get Started', 'button', w)).toBe(false);
|
|
expect(flowAllowed(f, 'Save draft', 'button', w)).toBe(f === 'play-content-rating');
|
|
expect(flowAllowed(f, 'Taslağı kaydet', 'button', w)).toBe(f === 'play-content-rating');
|
|
for (const n of ['Save', 'Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish']) expect(flowAllowed(f, n, 'button', w)).toBe(false);
|
|
}
|
|
expect(flowClass('play-content-rating', 'Save draft', 'button', w)).toBe('draft-save');
|
|
expect(flowClass('play-target-audience', 'Save', 'button', w)).toBe('final-submit');
|
|
expect(flowClass('play-content-rating', 'Submit', 'button', w)).toBe('final-submit');
|
|
});
|
|
test('every form has its buttons classified; only navigation and the agreed draft saves (App Privacy, content rating) are automatic', () => {
|
|
for (const [f, rules] of Object.entries(FORM_BUTTONS)) {
|
|
expect(rules.length).toBeGreaterThan(0);
|
|
for (const r of rules) if (r.auto) expect(r.class === 'navigation' || (['apple-app-privacy', 'play-content-rating'].includes(f) && r.class === 'draft-save')).toBe(true);
|
|
expect(rules.some(r => r.class === 'final-submit' && !r.auto)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-app-privacy-flow.html)', () => {
|
|
test('first time: Get Started > yes > Next > data types > Save > each type (purposes > Next > linked > Next > tracking > Save); Publish never clicked', async () => {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
|
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
|
const c = await clicks(b);
|
|
expect(c).not.toContain('Publish');
|
|
expect(c).not.toContain('See Details');
|
|
expect(c).not.toContain('Edit'); // neither the Privacy Policy Edit nor (first time) the Data Types one
|
|
expect(c.filter(x => x === 'Get Started')).toHaveLength(1);
|
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3); // the type list + Device ID + Crash Data, drafts
|
|
expect(await navClicks(b)).toContain('Crash Data'); // the link of the second type
|
|
expect(c).toContain('Set Up Device ID');
|
|
expect(log.lines).toContain('clicked "Save" (draft-save)');
|
|
expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false);
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
expect((await run).fallback).toBe(false);
|
|
}, TEST_MS);
|
|
|
|
test('answers exist: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html?existing=1', ...fast(log) }));
|
|
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
|
expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1);
|
|
expect(log.lines.some(m => m.startsWith('Data collection was answered before'))).toBe(true);
|
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
|
expect(await clicks(b)).not.toContain('Publish');
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
await run;
|
|
}, TEST_MS);
|
|
|
|
test('an uncertain answer stops the walk on its page until the owner sets it, then it goes on', async () => {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const config = privacy({ DEVICE_ID: { collected: true, purposes: ['ANALYTICS'], tracking: false } }); // linked: not in the config
|
|
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
|
await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Device ID: linked to the user"')));
|
|
await Bun.sleep(600); // several rounds: Next must not be clicked past the open question
|
|
const linked = b.page.getByRole('radio', { name: /^No, Device ID .* not linked/ });
|
|
expect(await linked.isVisible()).toBe(true);
|
|
expect(await b.page.getByRole('radio', { name: /^No, we do not use Device ID/ }).isVisible()).toBe(false);
|
|
await linked.check(); // the owner answers
|
|
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
|
expect(log.lines).toContain('"Device ID: linked to the user" answered: storebound goes on');
|
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3);
|
|
expect(await clicks(b)).not.toContain('Publish');
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
await run;
|
|
}, TEST_MS);
|
|
|
|
test('no data collected: "No" and the dialog\'s Save, then the summary', async () => {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const types = Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }]));
|
|
const config = { ...base0, dataSafety: undefined, appPrivacy: { types } } as unknown as ShipConfig;
|
|
const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html', ...fast(log) }));
|
|
await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH)));
|
|
expect(await b.page.evaluate(() => (window as any).__saves)).toBe(1);
|
|
expect(await clicks(b)).not.toContain('Next');
|
|
expect(await clicks(b)).not.toContain('Publish');
|
|
await b.context.close();
|
|
await run;
|
|
}, TEST_MS);
|
|
});
|
|
|
|
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
|
|
test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; Submit never clicked', async () => {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const run = scope.run(fillSession(b, { form: 'play-content-rating', config: base0, startUrl: base + 'play-content-rating.html', ...fast(log) }));
|
|
await log.wait('the questionnaire stop', l => l.some(m => m.startsWith('answer needed on "Questionnaire"')));
|
|
expect(log.lines).toContain('clicked "Save draft" (draft-save)');
|
|
expect(log.lines).toContain('clicked "Next" (navigation)');
|
|
expect(await clicks(b)).toEqual(['Save draft', 'Next']);
|
|
await Bun.sleep(500);
|
|
expect(await clicks(b)).toEqual(['Save draft', 'Next']); // nothing saved or moved past the open questions
|
|
// the owner answers every outlined question
|
|
const open = b.page.locator('[role="radiogroup"][data-storebound-check]');
|
|
for (let i = 0, n = await open.count(); i < n; i++) await open.nth(i).getByRole('radio', { name: 'No' }).check();
|
|
await log.wait('the final line', l => l.some(m => m.includes('press Submit yourself')));
|
|
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next']);
|
|
expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true);
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
await run;
|
|
}, TEST_MS);
|
|
|
|
test('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next; Save stays the owner\'s', async () => {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log) }));
|
|
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
|
|
expect(await clicks(b)).toEqual(['Next']);
|
|
await b.page.getByRole('radiogroup', { name: /appeal to children/ }).getByRole('radio', { name: 'No' }).check();
|
|
await log.wait('the final line', l => l.some(m => m.includes('press Save yourself')));
|
|
expect(await clicks(b)).toEqual(['Next', 'Next']);
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
await run;
|
|
}, TEST_MS);
|
|
|
|
test('New App (App Store) and Create app (Play): filled, the final button is the owner\'s, no navigation asked of the owner', async () => {
|
|
for (const [form, file, final] of [['apple-app-record', 'apple-app-record.html', 'Create'], ['play-app-record', 'play-create-app.html', 'Create app']] as const) {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const run = scope.run(fillSession(b, { form, config: base0, startUrl: base + file, ...fast(log) }));
|
|
await log.wait('the final line', l => l.some(m => m.includes(`press ${final} yourself`)));
|
|
expect(await clicks(b)).toEqual([]);
|
|
expect(navAsked(log.lines)).toEqual([]);
|
|
await b.context.close();
|
|
await run;
|
|
}
|
|
}, TEST_MS);
|
|
});
|
|
|
|
describe.skipIf(!browser)('watchdog: an unknown page after a click never means a silent wait', () => {
|
|
test('Next leads to a page storebound does not know: heartbeat, a sanitized debug dump, the manual steps, a bounded stop', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
|
try {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const started = Date.now();
|
|
const out = await scope.run(fillSession(b, {
|
|
form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-unknown.html', ...fast(log),
|
|
stepTimeoutMs: 1500, idleLimitMs: 5000, heartbeatMs: 700, debugDir: dir,
|
|
}));
|
|
expect(Date.now() - started).toBeLessThan(30_000);
|
|
expect(out.timedOut).toBe(true);
|
|
expect(out.debugDir).toBe(dir);
|
|
expect(b.isClosed()).toBe(true);
|
|
const said = log.lines.join('\n');
|
|
expect(said).toContain('clicked "Next" (navigation)');
|
|
expect(said).toMatch(/still waiting for the page after "Next", \d+ s/);
|
|
expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons there: .*"Done"/);
|
|
expect(said).toContain(`Debug saved to ${dir}`);
|
|
expect(said).toContain('do this part yourself');
|
|
expect(said).toContain('it stops here');
|
|
const [sub] = readdirSync(dir);
|
|
const files = readdirSync(join(dir, sub!)).sort();
|
|
expect(files).toEqual(['page.html', 'page.json', 'screen.jpg']);
|
|
const pj = JSON.parse(readFileSync(join(dir, sub!, 'page.json'), 'utf8'));
|
|
expect(pj.dialogs).toContain('Data Collection');
|
|
expect(pj.controls).toContainEqual({ role: 'switch', name: 'Contact details', checked: false });
|
|
expect(pj.url).toBe(`${base}apple-app-privacy-unknown.html`);
|
|
const all = readFileSync(join(dir, sub!, 'page.html'), 'utf8') + JSON.stringify(pj);
|
|
for (const leak of ['sb-token-should-not-leak', 'sb-csrf-should-not-leak', 'typed-by-owner-should-not-leak', 'owner@example.com', '010-9999', 'Example Owner']) expect(all).not.toContain(leak);
|
|
expect(all).toContain('[email]');
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
}, TEST_MS);
|
|
|
|
test('--debug-dump: a dump at the start, after each click and after each filled page', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-'));
|
|
try {
|
|
const b = await local();
|
|
const log = logLines();
|
|
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log), debugDir: dir, debugEveryStep: true }));
|
|
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
|
|
await Bun.sleep(500);
|
|
const names = readdirSync(dir).sort();
|
|
expect(names[0]).toBe('01-start');
|
|
expect(names.some(n => /-filled-target-age$/.test(n))).toBe(true);
|
|
expect(names.some(n => /-after-next$/.test(n))).toBe(true);
|
|
await b.context.close();
|
|
await run;
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
}, TEST_MS);
|
|
});
|
|
|
|
describe('every wait of the console engine is bounded (code scan)', () => {
|
|
const SRC = join(import.meta.dir, '..', 'cli', 'console');
|
|
const files = readdirSync(SRC).filter(f => f.endsWith('.ts'));
|
|
/** The text from `open` to its matching `close` (brackets balanced). */
|
|
const span = (s: string, at: number, open: string, close: string): string => {
|
|
let d = 0;
|
|
for (let i = s.indexOf(open, at); i >= 0 && i < s.length; i++) { if (s[i] === open) d++; else if (s[i] === close && --d === 0) return s.slice(at, i + 1); }
|
|
return s.slice(at);
|
|
};
|
|
test('a while loop always checks a deadline (Date.now / end) or the watchdog', () => {
|
|
const bad: string[] = [];
|
|
for (const f of files) {
|
|
const s = readFileSync(join(SRC, f), 'utf8');
|
|
for (const m of s.matchAll(/\bwhile \(/g)) {
|
|
const cond = span(s, m.index!, '(', ')'), body = span(s, m.index! + cond.length, '{', '}');
|
|
if (!/Date\.now\(\)|\bend\b|\bwatch\(/.test(cond + body)) bad.push(`${f}: ${cond.slice(0, 80)}`);
|
|
}
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
test('no Playwright wait without a timeout, and the window\'s close is awaited only after a bounded wait or a close()', () => {
|
|
const bad: string[] = [];
|
|
for (const f of files) {
|
|
const lines = readFileSync(join(SRC, f), 'utf8').split('\n');
|
|
lines.forEach((l, i) => {
|
|
if (/\.waitFor(Selector|URL|Event|LoadState|Function)?\(/.test(l) && !/timeout/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
|
if (/await [\w.]+\.closed\s*;/.test(l) && !lines.slice(Math.max(0, i - 3), i).some(p => /close\(\)/.test(p))) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
|
});
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
});
|