All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m2s
Review of PR #465 (comment 24953), suggestion 1: an alias (`const c = confirmAction; if (!c(...))`) called confirmAction out of reach of the "awaited" rule, and window["confirm"](...), confirm?.(...) and `const { confirm } = window` escaped the native-call rule. Any mention of confirmAction other than the awaited call, its declaration, a plain named import or `confirm: confirmAction` is now refused; the three native forms are flagged. Samples added for each. Refs #421 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
6.9 KiB
TypeScript
148 lines
6.9 KiB
TypeScript
/**
|
|
* Guard: no native `confirm` or `alert` in the app, and every confirmation awaited (#421).
|
|
*
|
|
* On desktop, tauri-plugin-dialog replaces `window.confirm` with an async function and
|
|
* `window.alert` with one that does not block. `if (!confirm(...)) return;` then tests a
|
|
* Promise, always truthy, and the deletion ran before the user answered. TypeScript does not
|
|
* see it: the DOM type of `confirm` stays `boolean`, and `!promise` is not an error.
|
|
*
|
|
* It reads the non-test sources of `src/`, comments removed, as `i18n-mock-guard.test.ts` does,
|
|
* and fails on:
|
|
* 1. a native `confirm` or `alert`: called bare or through `window`, `globalThis` or `self`
|
|
* (a member call such as `deps.confirm(` is not the native one), with `?.(` too, read by
|
|
* index (`window["confirm"]`) or destructured from one of those objects;
|
|
* 2. a call to `confirmAction(` or to an injected `confirm(` (`deps.confirm(`,
|
|
* `ui.confirm(`) that is not directly preceded by `await`;
|
|
* 3. any other mention of `confirmAction`: an alias (`const c = confirmAction`) or a renamed
|
|
* import would call it out of reach of rule 2 (#465 review). Allowed besides the awaited
|
|
* call: its declaration, a plain named import, and `confirm: confirmAction`, the value of
|
|
* an injected `confirm` whose calls rule 2 checks.
|
|
* `confirmAction` itself calls the plugin's `ask`, so `src/services/confirmService.ts` is
|
|
* scanned like any other file. Keywords rather than a parse: the samples below check that each
|
|
* rule flags what it targets and lets the right form through.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { readdirSync, readFileSync } from "fs";
|
|
import { relative, resolve, sep } from "path";
|
|
|
|
const SRC = resolve(import.meta.dirname, "..");
|
|
|
|
const NATIVE_CALL = /(?:\b(?:window|globalThis|self)\s*\.\s*|(?<![.\w$]))(?:confirm|alert)\s*(?:\?\.\s*)?\(/;
|
|
const NATIVE_INDEX = /\b(?:window|globalThis|self)\s*(?:\?\.\s*)?\[\s*["'`](?:confirm|alert)["'`]\s*\]/;
|
|
const NATIVE_DESTRUCTURED = /\{[^}]*\b(?:confirm|alert)\b[^}]*\}\s*=\s*(?:window|globalThis|self)\b/;
|
|
// `confirmAction(` or `<receiver>.confirm(`, the receiver captured to skip the native ones.
|
|
const CONFIRM_CALL = /\b(?:confirmAction|(\w+)\s*\.\s*confirm)\s*\(/g;
|
|
const NATIVE_RECEIVER = /^(?:window|globalThis|self)$/;
|
|
const AWAITED = /\bawait\s+$/;
|
|
const DECLARED = /\bfunction\s+$/;
|
|
// Mentions of `confirmAction` that rule 3 allows; any other one is an offence.
|
|
const CONFIRM_ACTION = /\bconfirmAction\b/g;
|
|
const ALLOWED_MENTION = [
|
|
/\bawait\s+confirmAction\s*\(/g,
|
|
/\bfunction\s+confirmAction\s*\(/g,
|
|
/^\s*import\s*\{\s*confirmAction\s*\}\s*from\s/g,
|
|
/\bconfirm\s*:\s*confirmAction\s*[,}]/g,
|
|
];
|
|
|
|
/** The source without its comments, line breaks kept so that line numbers stay right. */
|
|
function withoutComments(source: string): string {
|
|
return source
|
|
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""))
|
|
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
}
|
|
|
|
/** The offences of one line: a native call, or a confirmation not awaited. */
|
|
function lineOffences(line: string): string[] {
|
|
const found: string[] = [];
|
|
if (NATIVE_CALL.test(line) || NATIVE_INDEX.test(line) || NATIVE_DESTRUCTURED.test(line)) {
|
|
found.push("native confirm/alert");
|
|
}
|
|
for (const match of line.matchAll(CONFIRM_CALL)) {
|
|
if (match[1] !== undefined && NATIVE_RECEIVER.test(match[1])) continue;
|
|
const before = line.slice(0, match.index);
|
|
if (!AWAITED.test(before) && !DECLARED.test(before)) found.push("confirmation not awaited");
|
|
}
|
|
const allowed = ALLOWED_MENTION.reduce((n, rule) => n + [...line.matchAll(rule)].length, 0);
|
|
if ([...line.matchAll(CONFIRM_ACTION)].length > allowed) found.push("confirmAction out of reach");
|
|
return found;
|
|
}
|
|
|
|
/** The offending lines of one source, as `path:line: kind: text`. */
|
|
function offences(path: string, source: string): string[] {
|
|
const name = relative(SRC, path).split(sep).join("/");
|
|
return withoutComments(source)
|
|
.split("\n")
|
|
.flatMap((line, i) => lineOffences(line).map((kind) => `${name}:${i + 1}: ${kind}: ${line.trim()}`));
|
|
}
|
|
|
|
const SOURCE_FILES = (readdirSync(SRC, { recursive: true }) as string[])
|
|
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) && !entry.endsWith(".d.ts"))
|
|
.map((entry) => resolve(SRC, entry));
|
|
|
|
describe("native dialog guard (#421)", () => {
|
|
it("scans the app's sources", () => {
|
|
const names = SOURCE_FILES.map((path) => relative(SRC, path).split(sep).join("/"));
|
|
expect(names.length).toBeGreaterThan(200);
|
|
expect(names).toContain("components/profile/ProfileFormModal.tsx");
|
|
expect(names).toContain("hooks/useLicenseMachines.ts");
|
|
expect(names).toContain("services/confirmService.ts");
|
|
});
|
|
|
|
it.each([
|
|
"if (!confirm(t(\"profile.deleteConfirm\"))) return;",
|
|
"if (confirm(message)) {",
|
|
"if (!window.confirm(message)) return;",
|
|
"onClick={() => removeKey({ confirm: (m) => window.confirm(m), t })}",
|
|
"globalThis.confirm(message);",
|
|
"self . alert(message);",
|
|
"alert(message);",
|
|
"if (!window[\"confirm\"](message)) return;",
|
|
"globalThis['alert'](message);",
|
|
"if (!confirm?.(message)) return;",
|
|
"const { confirm } = window;",
|
|
"const { alert: show, confirm: ask } = globalThis;",
|
|
])("flags the native call in %s", (line) => {
|
|
expect(lineOffences(line)).toContain("native confirm/alert");
|
|
});
|
|
|
|
it.each([
|
|
"if (!confirmAction(t(\"categories.deleteConfirm\"))) return;",
|
|
"if (confirmAction(message)) {",
|
|
"if (!deps.confirm(deps.t(key))) return;",
|
|
"const ok = ui.confirm(message);",
|
|
])("flags the confirmation not awaited in %s", (line) => {
|
|
expect(lineOffences(line)).toContain("confirmation not awaited");
|
|
});
|
|
|
|
it.each([
|
|
"const c = confirmAction;",
|
|
"import { confirmAction as ask } from \"../services/confirmService\";",
|
|
"const handlers = { remove: confirmAction };",
|
|
"items.map(confirmAction);",
|
|
"if (await confirmAction(a) && check(confirmAction)) {",
|
|
])("flags confirmAction out of reach in %s", (line) => {
|
|
expect(lineOffences(line)).toContain("confirmAction out of reach");
|
|
});
|
|
|
|
it.each([
|
|
"if (!(await confirmAction(t(\"profile.deleteConfirm\")))) return;",
|
|
"if (await confirmAction(message)) {",
|
|
"if (!(await deps.confirm(deps.t(key)))) return;",
|
|
"const ok = await confirmAction(message);",
|
|
"onClick={() => void removeKey({ confirm: confirmAction, t })}",
|
|
"import { confirmAction } from \"../../services/confirmService\";",
|
|
"export async function confirmAction(message: string): Promise<boolean> {",
|
|
"if ((await confirmAction(a)) && (await confirmAction(b))) {",
|
|
"deps.confirm.mockClear();",
|
|
"const confirmed = await ask(message, { kind: \"warning\" });",
|
|
"t(\"common.confirm\")",
|
|
])("lets %s through", (line) => {
|
|
expect(lineOffences(line)).toEqual([]);
|
|
});
|
|
|
|
it("finds no native dialog and no confirmation left unawaited", () => {
|
|
const found = SOURCE_FILES.flatMap((path) => offences(path, readFileSync(path, "utf8")));
|
|
expect(found).toEqual([]);
|
|
});
|
|
});
|