@cosmicdrift/kumiko-guards 0.309.0 → 0.311.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.311.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.311.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.311.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "test-timeouts is now always-enforcing, plus two new guards (test-template-drift, Real-Provider-Isolation)",
|
|
6
|
+
"detail": "test-timeouts drops its transitional baseline/ratchet — any sleep loop,\ntest.setTimeout/test.slow, or waitForTimeout finding fails the guard run\ndirectly, with no `.kumiko-test-timeouts-baseline.json` grandfathering\npre-existing violations.\nNew AST guard test-template-drift flags apps reimplementing the\nkumiko-testing e2e/screenshot template locally: a Playwright config that\nbuilds its own defineConfig(...) instead of calling defineAppE2eConfig(),\na literal deviceScaleFactor override anywhere (test.use, a project's use,\nor the config's own use block), a literal viewport override in a\nplaywright*.config.ts's own use or a project's use (a spec's own\ntest.use({ viewport }) is not flagged — it's the documented way to assert a\nlayout that only exists below the template's 1920px default; a\n`...devices[\"…\"]` spread stays allowed everywhere), or a direct\npage.screenshot(...) call instead of captureScreenshot.\nNew repo check Real-Provider-Isolation flags a CI workflow that references\nKUMIKO_REAL_PROVIDERS or invokes test:real/e2e:real, a package.json script\nother than test:real/e2e:real that leaks the real-provider env or a\n*.real. spec/test reference, and one of the template's own non-real bunfig\nfiles (bunfig.toml, bunfig.integration.toml, bunfig.dom.toml) missing the\n**/*.real.test.ts exclusion.",
|
|
7
|
+
"migration": "Fix or mark each test-timeouts finding: replace hand-rolled poll loops with\nthe shared `waitFor` from @cosmicdrift/kumiko-framework/testing, or add\n`// @timeout-exception: #<issue> <technical reason>` on the loop's own line\nor the line above for a genuine non-condition wait. Delete any committed\n`.kumiko-test-timeouts-baseline.json`.\nFor test-template-drift: switch a hand-rolled Playwright/screenshot config\nto defineAppE2eConfig(), drop config-level viewport/deviceScaleFactor\noverrides (the template already sets DESKTOP_VIEWPORT and renders\nscreenshot runs at SCREENSHOT_DEVICE_SCALE_FACTOR), and replace direct\npage.screenshot(...) calls with the template's captureScreenshot. Specs\nasserting a layout that only exists below 1920px set their own viewport via\ntest.use({ viewport }) — no exception marker needed. A deliberate exception\nfor anything else the guard flags (e.g. a handbook screenshot helper that\nneeds its own viewport-growth pass) is\n`// @template-drift-exception: #<issue> <technical reason>` on the line\nabove.\nFor Real-Provider-Isolation: remove any KUMIKO_REAL_PROVIDERS/test:real/\ne2e:real reference from CI workflows, move real-provider env/spec\nreferences out of scripts other than test:real/e2e:real, and regenerate\nbunfig.toml/bunfig.integration.toml/bunfig.dom.toml via\n`kumiko-testing bunfig` instead of hand-editing pathIgnorePatterns."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.308.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Real-Provider-Isolation-Guard (stack-agnostisch, #3118).
|
|
4
|
+
*
|
|
5
|
+
* Real-provider specs (`*.real.test.ts`, `*.real.spec.ts`) hit live third-party
|
|
6
|
+
* services and must only ever run manually, opt-in, via `bun run test:real` /
|
|
7
|
+
* `bun run e2e:real` — never inside a default `bun test`/`playwright test`
|
|
8
|
+
* glob and never inside CI (an accidental `KUMIKO_REAL_PROVIDERS=1` in a
|
|
9
|
+
* workflow spends real quota/credentials on every push).
|
|
10
|
+
*
|
|
11
|
+
* Flags:
|
|
12
|
+
* (a) any CI workflow (`.github/workflows/*.{yml,yaml}`) that references
|
|
13
|
+
* KUMIKO_REAL_PROVIDERS, `test:real`, or `e2e:real` — CI must never run
|
|
14
|
+
* real-provider tests, full stop, so any mention is a violation.
|
|
15
|
+
* (b) one of the template's own non-`real` bunfig files (`bunfig.toml`,
|
|
16
|
+
* `bunfig.integration.toml`, `bunfig.dom.toml` — see BUNFIG_FILES in
|
|
17
|
+
* packages/testing/src/bunfig.ts) whose pathIgnorePatterns doesn't
|
|
18
|
+
* exclude `**\/*.real.test.ts` — the default-glob discovery trap. A
|
|
19
|
+
* repo-specific bunfig outside that set (e.g. a coverage-ratchet
|
|
20
|
+
* config invoked with an explicit path filter, never a bare
|
|
21
|
+
* `bun test`) is not the template's concern and is left alone.
|
|
22
|
+
* (c) a package.json script other than `test:real`/`e2e:real` that sets
|
|
23
|
+
* KUMIKO_REAL_PROVIDERS or references a `.real.` spec/test file.
|
|
24
|
+
*
|
|
25
|
+
* `bunfig.real.toml` itself, and the `test:real`/`e2e:real` scripts, are the
|
|
26
|
+
* accepted, designated home for all three signals — see
|
|
27
|
+
* packages/testing/src/scaffold.ts and packages/testing/src/bunfig.ts, which
|
|
28
|
+
* generate them. Apps that use kumiko-testing's scaffold never trip this;
|
|
29
|
+
* it catches a hand-rolled or drifted bunfig/CI setup.
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { Glob } from "bun";
|
|
34
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
35
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
36
|
+
import { scanLinesForPredicate, type TextLineFinding } from "./_lib/scan-lines";
|
|
37
|
+
|
|
38
|
+
const REAL_PROVIDERS_ENV = "KUMIKO_REAL_PROVIDERS";
|
|
39
|
+
const REAL_SCRIPT_NAMES: ReadonlySet<string> = new Set(["test:real", "e2e:real"]);
|
|
40
|
+
const REAL_TEST_IGNORE_ENTRY = "**/*.real.test.ts";
|
|
41
|
+
|
|
42
|
+
const CI_WORKFLOW_GLOB = ".github/workflows/*.{yml,yaml}";
|
|
43
|
+
// Only the template's own generated bunfig files (BUNFIG_FILES in
|
|
44
|
+
// packages/testing/src/bunfig.ts) minus the real variant itself — a
|
|
45
|
+
// hand-rolled bunfig outside this set (coverage ratchet, CI-only tweaks)
|
|
46
|
+
// isn't what the template owns and is never invoked as a bare `bun test`.
|
|
47
|
+
const TEMPLATE_OWNED_NON_REAL_BUNFIGS: readonly string[] = [
|
|
48
|
+
"bunfig.toml",
|
|
49
|
+
"bunfig.integration.toml",
|
|
50
|
+
"bunfig.dom.toml",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function ciWorkflowViolation(line: string): boolean {
|
|
54
|
+
return (
|
|
55
|
+
line.includes(REAL_PROVIDERS_ENV) || /\btest:real\b/.test(line) || /\be2e:real\b/.test(line)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scanCiWorkflows(root: RepoRoot, findings: TextLineFinding[]): number {
|
|
60
|
+
let scanned = 0;
|
|
61
|
+
for (const rel of new Glob(CI_WORKFLOW_GLOB).scanSync({ cwd: root.absPath })) {
|
|
62
|
+
scanned++;
|
|
63
|
+
scanLinesForPredicate(join(root.absPath, rel), rel, ciWorkflowViolation, findings);
|
|
64
|
+
}
|
|
65
|
+
return scanned;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// package.json scripts: a real-provider signal in any script OTHER than the
|
|
69
|
+
// two designated ones is a leak (e.g. a "test" or "ci" script that pulls in
|
|
70
|
+
// the real env or filters for a .real. spec by hand).
|
|
71
|
+
function scriptLeaksRealProvider(name: string, command: string): string | undefined {
|
|
72
|
+
if (REAL_SCRIPT_NAMES.has(name)) return undefined;
|
|
73
|
+
if (command.includes(REAL_PROVIDERS_ENV)) {
|
|
74
|
+
return `script "${name}" sets ${REAL_PROVIDERS_ENV} outside test:real/e2e:real: ${command}`;
|
|
75
|
+
}
|
|
76
|
+
if (/\.real\.(test|spec)\.ts/.test(command)) {
|
|
77
|
+
return `script "${name}" references a *.real. spec/test outside test:real/e2e:real: ${command}`;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scanPackageJsonScripts(root: RepoRoot, findings: TextLineFinding[]): number {
|
|
83
|
+
const path = join(root.absPath, "package.json");
|
|
84
|
+
let parsed: { scripts?: Record<string, string> };
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(readFileSync(path, "utf8")) as { scripts?: Record<string, string> };
|
|
87
|
+
} catch {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
const scripts = parsed.scripts ?? {};
|
|
91
|
+
let scanned = 0;
|
|
92
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
93
|
+
scanned++;
|
|
94
|
+
const message = scriptLeaksRealProvider(name, command);
|
|
95
|
+
if (message !== undefined) {
|
|
96
|
+
findings.push({ file: "package.json", line: 1, text: message });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return scanned;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The generated bunfig template (packages/testing/src/bunfig.ts) is what app
|
|
103
|
+
// and library repos consume via `kumiko-testing bunfig` — the framework repo
|
|
104
|
+
// hand-maintains its own root bunfig*.toml files and doesn't run this
|
|
105
|
+
// template on itself, so it's excluded here to avoid flagging a convention
|
|
106
|
+
// it was never meant to follow.
|
|
107
|
+
function bunfigMissingRealExclusion(root: RepoRoot, findings: TextLineFinding[]): number {
|
|
108
|
+
if (root.kind === "framework") return 0;
|
|
109
|
+
let scanned = 0;
|
|
110
|
+
for (const rel of TEMPLATE_OWNED_NON_REAL_BUNFIGS) {
|
|
111
|
+
const abs = join(root.absPath, rel);
|
|
112
|
+
let content: string;
|
|
113
|
+
try {
|
|
114
|
+
content = readFileSync(abs, "utf8");
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
scanned++;
|
|
119
|
+
if (!content.includes(REAL_TEST_IGNORE_ENTRY)) {
|
|
120
|
+
findings.push({
|
|
121
|
+
file: rel,
|
|
122
|
+
line: 1,
|
|
123
|
+
text: `[test] pathIgnorePatterns is missing "${REAL_TEST_IGNORE_ENTRY}" — a default \`bun test\` would pick up real-provider specs`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return scanned;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const check: RepoCheck = {
|
|
131
|
+
name: "Real-Provider-Isolation Guard",
|
|
132
|
+
hint:
|
|
133
|
+
`Real-provider tests run only via \`bun run test:real\`/\`bun run e2e:real\`, ` +
|
|
134
|
+
`never in CI and never in a default test glob. Regenerate bunfig via ` +
|
|
135
|
+
`\`kumiko-testing bunfig\` instead of hand-editing pathIgnorePatterns.`,
|
|
136
|
+
async run(roots) {
|
|
137
|
+
if (roots.length === 0) {
|
|
138
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
139
|
+
}
|
|
140
|
+
const findings: TextLineFinding[] = [];
|
|
141
|
+
let matchedFiles = 0;
|
|
142
|
+
for (const root of roots) {
|
|
143
|
+
matchedFiles += scanCiWorkflows(root, findings);
|
|
144
|
+
matchedFiles += scanPackageJsonScripts(root, findings);
|
|
145
|
+
matchedFiles += bunfigMissingRealExclusion(root, findings);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
violations: findings.map((f) => ({ file: f.file, line: f.line, message: f.text })),
|
|
149
|
+
matchedFiles,
|
|
150
|
+
notApplicable: false,
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (import.meta.main) {
|
|
156
|
+
const failed = reportResults(await runRepoChecks([check], resolveRepoRoots()));
|
|
157
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
158
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: apps must not locally reimplement the e2e/screenshot template that
|
|
4
|
+
* `@cosmicdrift/kumiko-testing` already owns (#3118). Flags, in Playwright
|
|
5
|
+
* configs and e2e/screenshot specs/helpers:
|
|
6
|
+
* (a) a `playwright*.config.ts` that imports `defineConfig` from
|
|
7
|
+
* `@playwright/test` directly instead of `defineAppE2eConfig` from
|
|
8
|
+
* `@cosmicdrift/kumiko-testing/e2e` — a hand-rolled config drifts from
|
|
9
|
+
* the template's timeouts/retries/workers/real-provider wiring.
|
|
10
|
+
* (b) a literal `deviceScaleFactor: <n>` property anywhere (`test.use(...)`,
|
|
11
|
+
* a project's `use`, or a config's own `use`), or a literal
|
|
12
|
+
* `viewport: {…}` property in a `playwright*.config.ts`'s own `use` or
|
|
13
|
+
* a project's `use` — instead of the template's `DESKTOP_VIEWPORT` /
|
|
14
|
+
* `SCREENSHOT_DEVICE_SCALE_FACTOR`. A spec's own `test.use({ viewport })`
|
|
15
|
+
* is not flagged — the 0.310.0 changeset documents it as the way to
|
|
16
|
+
* assert a layout that only exists below the template's 1920px
|
|
17
|
+
* default. A `...devices["…"]` spread is not a property assignment and
|
|
18
|
+
* is never flagged — device-matrix projects (phone/tablet passes via
|
|
19
|
+
* `runMatrix`) stay allowed.
|
|
20
|
+
* (c) a direct `page.screenshot(...)` call — the template's
|
|
21
|
+
* `captureScreenshot` (fit: viewport/fullPage/content) is the one path
|
|
22
|
+
* apps should call.
|
|
23
|
+
*
|
|
24
|
+
* `packages/testing/**` (the template's own implementation) and
|
|
25
|
+
* `samples/recipes/**` are never scanned.
|
|
26
|
+
*
|
|
27
|
+
* Opt-out on the finding's own line or the line above:
|
|
28
|
+
* `// @template-drift-exception: #<issue> <technical reason>`.
|
|
29
|
+
*
|
|
30
|
+
* Usage (framework dev checkout):
|
|
31
|
+
* bun guards/guard-test-template-drift.ts
|
|
32
|
+
* Consumer (via the published `kumiko-guards` CLI):
|
|
33
|
+
* kumiko-guards guards --guard=test-template-drift
|
|
34
|
+
*/
|
|
35
|
+
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
36
|
+
import {
|
|
37
|
+
type AstGuard,
|
|
38
|
+
type GuardOutcome,
|
|
39
|
+
type GuardViolation,
|
|
40
|
+
isLocalFinding,
|
|
41
|
+
relFromRepoRoot,
|
|
42
|
+
runStandalone,
|
|
43
|
+
type ScanSpec,
|
|
44
|
+
} from "./_lib/guard-kit";
|
|
45
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
46
|
+
|
|
47
|
+
const SCAN: ScanSpec = {
|
|
48
|
+
scope: "tests",
|
|
49
|
+
extensions: ["ts", "tsx"],
|
|
50
|
+
kinds: ["framework", "app", "library"],
|
|
51
|
+
frameworkWithin: ["samples/apps/*/playwright*.config.ts", "samples/apps/*/e2e/**/*.{ts,tsx}"],
|
|
52
|
+
// frameworkWithin only narrows what's already collected here — the
|
|
53
|
+
// samples/apps globs must be listed in extraGlobs too, or nothing under
|
|
54
|
+
// samples/apps ever reaches the framework-kind scan in the first place.
|
|
55
|
+
extraGlobs: [
|
|
56
|
+
"playwright*.config.ts",
|
|
57
|
+
"e2e/**/*.{ts,tsx}",
|
|
58
|
+
"packages/*/e2e/**/*.{ts,tsx}",
|
|
59
|
+
"packages/*/src/e2e/**/*.{ts,tsx}",
|
|
60
|
+
"samples/apps/*/playwright*.config.ts",
|
|
61
|
+
"samples/apps/*/e2e/**/*.{ts,tsx}",
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const TESTING_DIR = "/packages/testing/";
|
|
66
|
+
const RECIPES_DIR = "/samples/recipes/";
|
|
67
|
+
const EXCEPTION_TAG = "@template-drift-exception";
|
|
68
|
+
const COMPLETE_EXCEPTION = /@template-drift-exception:\s*#\d+\s+\S/;
|
|
69
|
+
|
|
70
|
+
const CONFIG_FILENAME = /(?:^|\/)playwright[^/]*\.config\.tsx?$/;
|
|
71
|
+
const DEFINE_APP_E2E_CONFIG = "defineAppE2eConfig";
|
|
72
|
+
const DEFINE_APP_E2E_CONFIG_MODULE = "@cosmicdrift/kumiko-testing/e2e";
|
|
73
|
+
const RAW_DEFINE_CONFIG_MODULE = "@playwright/test";
|
|
74
|
+
|
|
75
|
+
const TEMPLATE_OWNED_KEYS: ReadonlySet<string> = new Set(["viewport", "deviceScaleFactor"]);
|
|
76
|
+
|
|
77
|
+
export interface Finding {
|
|
78
|
+
file: string;
|
|
79
|
+
line: number;
|
|
80
|
+
message: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
|
|
84
|
+
return relFromRepoRoot(sf.getFilePath(), roots);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function exceptionNote(node: Node): "allowed" | "incomplete" | "none" {
|
|
88
|
+
const lines = node.getSourceFile().getFullText().split("\n");
|
|
89
|
+
const line = node.getStartLineNumber();
|
|
90
|
+
const nearby = [lines[line - 1] ?? "", lines[line - 2] ?? ""];
|
|
91
|
+
if (nearby.some((text) => COMPLETE_EXCEPTION.test(text))) return "allowed";
|
|
92
|
+
return nearby.some((text) => text.includes(EXCEPTION_TAG)) ? "incomplete" : "none";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function withExceptionNote(node: Node, message: string): string | undefined {
|
|
96
|
+
const note = exceptionNote(node);
|
|
97
|
+
if (note === "allowed") return undefined;
|
|
98
|
+
return note === "incomplete"
|
|
99
|
+
? `${message} [incomplete ${EXCEPTION_TAG} marker: needs "#<issue> <technical reason>"]`
|
|
100
|
+
: message;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function importsFrom(sf: SourceFile, moduleSuffix: string): boolean {
|
|
104
|
+
return sf
|
|
105
|
+
.getImportDeclarations()
|
|
106
|
+
.some((imp) => imp.getModuleSpecifierValue().endsWith(moduleSuffix));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function callsDefineAppE2eConfig(sf: SourceFile): boolean {
|
|
110
|
+
return sf
|
|
111
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
112
|
+
.some((call) => call.getExpression().getText() === DEFINE_APP_E2E_CONFIG);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A raw `defineConfig` import next to a missing `defineAppE2eConfig` call is
|
|
116
|
+
// the hand-rolled-config signal — a file that never imports Playwright's
|
|
117
|
+
// `defineConfig` at all (e.g. a fixture/helper next to the config) isn't one.
|
|
118
|
+
function ownConfigFinding(sf: SourceFile): Finding | undefined {
|
|
119
|
+
if (!CONFIG_FILENAME.test(sf.getFilePath())) return undefined;
|
|
120
|
+
if (!importsFrom(sf, RAW_DEFINE_CONFIG_MODULE)) return undefined;
|
|
121
|
+
if (importsFrom(sf, DEFINE_APP_E2E_CONFIG_MODULE) && callsDefineAppE2eConfig(sf))
|
|
122
|
+
return undefined;
|
|
123
|
+
const message = withExceptionNote(
|
|
124
|
+
sf,
|
|
125
|
+
`Playwright config builds its own defineConfig(...) instead of defineAppE2eConfig() — timeouts, retries, workers and the real-provider spec split drift from the template.`,
|
|
126
|
+
);
|
|
127
|
+
return message === undefined ? undefined : { file: "", line: 1, message };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A `viewport`/`deviceScaleFactor` key is only a Playwright config/test.use
|
|
131
|
+
// override — the drift this guard cares about — when the object literal that
|
|
132
|
+
// holds it is a `test.use(...)` argument or the value of a `use:` property
|
|
133
|
+
// (a config's own use block, or a project's use block). The same key names
|
|
134
|
+
// are also legitimate on unrelated app-owned option objects (e.g. runMatrix's
|
|
135
|
+
// `Scenario.viewport`, a GIF-recorder's per-clip viewport), which must not be
|
|
136
|
+
// flagged.
|
|
137
|
+
function isPlaywrightUseObject(node: Node): boolean {
|
|
138
|
+
const parent = node.getParent();
|
|
139
|
+
if (parent === undefined) return false;
|
|
140
|
+
if (Node.isCallExpression(parent)) {
|
|
141
|
+
const callee = parent.getExpression().getText();
|
|
142
|
+
return callee === "test.use" || callee.endsWith(".use");
|
|
143
|
+
}
|
|
144
|
+
if (Node.isPropertyAssignment(parent)) return parent.getName() === "use";
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function templateOwnedKeyFindings(sf: SourceFile): Finding[] {
|
|
149
|
+
const findings: Finding[] = [];
|
|
150
|
+
const isConfigFile = CONFIG_FILENAME.test(sf.getFilePath());
|
|
151
|
+
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
152
|
+
const name = prop.getName();
|
|
153
|
+
if (!TEMPLATE_OWNED_KEYS.has(name)) continue;
|
|
154
|
+
// viewport is only drift in the config's own use/project use — a spec's
|
|
155
|
+
// own test.use({ viewport }) is the documented way (0.310.0 changeset) to
|
|
156
|
+
// assert a layout that only exists below the template's 1920px default.
|
|
157
|
+
// deviceScaleFactor has no such legitimate per-spec override, so it stays
|
|
158
|
+
// flagged everywhere.
|
|
159
|
+
if (name === "viewport" && !isConfigFile) continue;
|
|
160
|
+
const objectLiteral = prop.getParent();
|
|
161
|
+
if (!Node.isObjectLiteralExpression(objectLiteral)) continue;
|
|
162
|
+
if (!isPlaywrightUseObject(objectLiteral)) continue;
|
|
163
|
+
const message = withExceptionNote(
|
|
164
|
+
prop,
|
|
165
|
+
`Literal "${name}" — use the template's DESKTOP_VIEWPORT/SCREENSHOT_DEVICE_SCALE_FACTOR (defineAppE2eConfig) instead of overriding it locally.`,
|
|
166
|
+
);
|
|
167
|
+
if (message !== undefined)
|
|
168
|
+
findings.push({ file: "", line: prop.getStartLineNumber(), message });
|
|
169
|
+
}
|
|
170
|
+
return findings;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isPageScreenshotCall(call: Node): boolean {
|
|
174
|
+
if (!Node.isCallExpression(call)) return false;
|
|
175
|
+
const callee = call.getExpression();
|
|
176
|
+
if (!Node.isPropertyAccessExpression(callee)) return false;
|
|
177
|
+
if (callee.getName() !== "screenshot") return false;
|
|
178
|
+
return /(?:^|\.)page$/i.test(callee.getExpression().getText());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function pageScreenshotFindings(sf: SourceFile): Finding[] {
|
|
182
|
+
const findings: Finding[] = [];
|
|
183
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
184
|
+
if (!isPageScreenshotCall(call)) continue;
|
|
185
|
+
const message = withExceptionNote(
|
|
186
|
+
call,
|
|
187
|
+
`Direct page.screenshot(...) call — use the template's captureScreenshot (fit: viewport/fullPage/content) instead of a local reimplementation.`,
|
|
188
|
+
);
|
|
189
|
+
if (message !== undefined)
|
|
190
|
+
findings.push({ file: "", line: call.getStartLineNumber(), message });
|
|
191
|
+
}
|
|
192
|
+
return findings;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function scanTemplateDrift(
|
|
196
|
+
sf: SourceFile,
|
|
197
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
198
|
+
): Finding[] {
|
|
199
|
+
const path = sf.getFilePath();
|
|
200
|
+
if (path.includes(TESTING_DIR) || path.includes(RECIPES_DIR)) return [];
|
|
201
|
+
const file = relFile(sf, roots);
|
|
202
|
+
const findings: Finding[] = [];
|
|
203
|
+
const own = ownConfigFinding(sf);
|
|
204
|
+
if (own !== undefined) findings.push({ ...own, file });
|
|
205
|
+
for (const f of [...templateOwnedKeyFindings(sf), ...pageScreenshotFindings(sf)]) {
|
|
206
|
+
findings.push({ ...f, file });
|
|
207
|
+
}
|
|
208
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const REMEDIATION = `Mark a deliberate exception with \`// ${EXCEPTION_TAG}: #<issue> <technical reason>\`.`;
|
|
212
|
+
|
|
213
|
+
const HINT =
|
|
214
|
+
"Apps consume the e2e/screenshot template through defineAppE2eConfig, not a hand-rolled Playwright config or a local viewport/DPR/screenshot reimplementation. See https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/testing-standard.md.";
|
|
215
|
+
|
|
216
|
+
function analyse(files: readonly SourceFile[], roots: readonly RepoRoot[]): GuardOutcome {
|
|
217
|
+
const findings: Finding[] = [];
|
|
218
|
+
for (const sf of files) {
|
|
219
|
+
for (const finding of scanTemplateDrift(sf, roots)) {
|
|
220
|
+
findings.push(finding);
|
|
221
|
+
console.warn(
|
|
222
|
+
` [test-template-drift WARN] ${finding.file}:${finding.line} ${finding.message}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const violations: GuardViolation[] = findings
|
|
227
|
+
.filter(isLocalFinding)
|
|
228
|
+
.map((f) => ({ file: f.file, line: f.line, message: `${f.message} ${REMEDIATION}` }));
|
|
229
|
+
return { violations };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const guard: AstGuard = {
|
|
233
|
+
name: "test-template-drift",
|
|
234
|
+
scan: SCAN,
|
|
235
|
+
hint: HINT,
|
|
236
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots),
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -16,25 +16,19 @@
|
|
|
16
16
|
* without an issue number or a reason does not count and is called out in
|
|
17
17
|
* the message.
|
|
18
18
|
*
|
|
19
|
-
* `samples/recipes/**` is not touched.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* `samples/recipes/**` is not touched. No baseline, no ratchet: any finding
|
|
20
|
+
* fails. #3118's transition period (a `.kumiko-test-timeouts-baseline.json`
|
|
21
|
+
* grandfathering pre-existing sleep loops) is over — every consumer must fix
|
|
22
|
+
* or mark the cause, not accumulate a pinned count.
|
|
23
23
|
*
|
|
24
24
|
* Usage (framework dev checkout):
|
|
25
|
-
* bun guards/guard-test-timeouts.ts
|
|
26
|
-
* bun guards/guard-test-timeouts.ts --write-baseline # freeze current
|
|
27
|
-
* bun guards/guard-test-timeouts.ts --no-baseline # skip comparison
|
|
25
|
+
* bun guards/guard-test-timeouts.ts
|
|
28
26
|
* Consumer (via the published `kumiko-guards` CLI):
|
|
29
|
-
* kumiko-guards guards --
|
|
27
|
+
* kumiko-guards guards --guard=test-timeouts
|
|
30
28
|
*/
|
|
31
|
-
import * as path from "node:path";
|
|
32
29
|
import { type CallExpression, Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
33
30
|
import {
|
|
34
31
|
type AstGuard,
|
|
35
|
-
baselineRatchet,
|
|
36
|
-
buildSharedProject,
|
|
37
|
-
filesForGuard,
|
|
38
32
|
type GuardOutcome,
|
|
39
33
|
type GuardViolation,
|
|
40
34
|
isLocalFinding,
|
|
@@ -44,8 +38,6 @@ import {
|
|
|
44
38
|
} from "./_lib/guard-kit";
|
|
45
39
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
46
40
|
|
|
47
|
-
const ROOT = process.cwd();
|
|
48
|
-
|
|
49
41
|
// Playwright dirs sit outside every manifest's sourceRoots/testGlobs, and
|
|
50
42
|
// scenario helpers next to the specs (not just *.spec.ts) carry the timeouts too.
|
|
51
43
|
const SCAN: ScanSpec = {
|
|
@@ -181,30 +173,12 @@ export function scanTimeouts(
|
|
|
181
173
|
return findings.sort((a, b) => a.line - b.line);
|
|
182
174
|
}
|
|
183
175
|
|
|
184
|
-
const BASELINE_FILE = ".kumiko-test-timeouts-baseline.json";
|
|
185
|
-
const timeoutsBaseline = baselineRatchet({
|
|
186
|
-
file: path.join(ROOT, BASELINE_FILE),
|
|
187
|
-
formatVersion: 1,
|
|
188
|
-
unit: "test timeout workaround(s)",
|
|
189
|
-
});
|
|
190
|
-
|
|
191
176
|
const REMEDIATION = `Wait for a condition (\`waitFor\`, \`expect.poll\`) instead of raising timeouts or sleeping, or mark the line with \`// ${EXCEPTION_TAG}: #<issue> <technical reason>\`.`;
|
|
192
177
|
|
|
193
178
|
const HINT =
|
|
194
|
-
"Diagnose in this order: (1) reproduce a single run (`--repeat-each`, single file), (2) check shared state and missing isolation (one tenant per flow), (3) wait for a condition, not for time (`waitFor`, `expect.poll`), (4) check the seed, (5) only then change the central template via an issue — never per app. See https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md.
|
|
179
|
+
"Diagnose in this order: (1) reproduce a single run (`--repeat-each`, single file), (2) check shared state and missing isolation (one tenant per flow), (3) wait for a condition, not for time (`waitFor`, `expect.poll`), (4) check the seed, (5) only then change the central template via an issue — never per app. See https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md.";
|
|
195
180
|
|
|
196
|
-
|
|
197
|
-
const counts: Record<string, number> = {};
|
|
198
|
-
for (const finding of findings.filter(isLocalFinding)) {
|
|
199
|
-
counts[finding.file] = (counts[finding.file] ?? 0) + 1;
|
|
200
|
-
}
|
|
201
|
-
return counts;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function scan(
|
|
205
|
-
files: readonly SourceFile[],
|
|
206
|
-
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
207
|
-
): Finding[] {
|
|
181
|
+
function analyse(files: readonly SourceFile[], roots: readonly RepoRoot[]): GuardOutcome {
|
|
208
182
|
const findings: Finding[] = [];
|
|
209
183
|
for (const sf of files) {
|
|
210
184
|
for (const finding of scanTimeouts(sf, roots)) {
|
|
@@ -212,29 +186,9 @@ function scan(
|
|
|
212
186
|
console.warn(` [test-timeouts WARN] ${finding.file}:${finding.line} ${finding.message}`);
|
|
213
187
|
}
|
|
214
188
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
function analyse(
|
|
219
|
-
files: readonly SourceFile[],
|
|
220
|
-
roots: readonly RepoRoot[],
|
|
221
|
-
compareBaseline: boolean,
|
|
222
|
-
): GuardOutcome {
|
|
223
|
-
const findings = scan(files, roots);
|
|
224
|
-
if (!compareBaseline) {
|
|
225
|
-
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
226
|
-
return { violations: [] };
|
|
227
|
-
}
|
|
228
|
-
const local = findings.filter(isLocalFinding);
|
|
229
|
-
const violations: GuardViolation[] = timeoutsBaseline.check(
|
|
230
|
-
baselineCounts(findings),
|
|
231
|
-
REMEDIATION,
|
|
232
|
-
{
|
|
233
|
-
formatDriftRemediation:
|
|
234
|
-
"Run `kumiko-guards guards --write-baseline --guard=test-timeouts` once.",
|
|
235
|
-
resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
|
|
236
|
-
},
|
|
237
|
-
);
|
|
189
|
+
const violations: GuardViolation[] = findings
|
|
190
|
+
.filter(isLocalFinding)
|
|
191
|
+
.map((f) => ({ file: f.file, line: f.line, message: `${f.message} ${REMEDIATION}` }));
|
|
238
192
|
return { violations };
|
|
239
193
|
}
|
|
240
194
|
|
|
@@ -242,24 +196,7 @@ export const guard: AstGuard = {
|
|
|
242
196
|
name: "test-timeouts",
|
|
243
197
|
scan: SCAN,
|
|
244
198
|
hint: HINT,
|
|
245
|
-
run: (files, roots = resolveRepoRoots()) => analyse(files, roots
|
|
246
|
-
writeBaseline: (files) => timeoutsBaseline.write(baselineCounts(scan(files))),
|
|
199
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots),
|
|
247
200
|
};
|
|
248
201
|
|
|
249
|
-
|
|
250
|
-
// guard with the same argv, and a --write-baseline meant for another guard
|
|
251
|
-
// must not silently refreeze this one.
|
|
252
|
-
if (import.meta.main) {
|
|
253
|
-
const args = process.argv.slice(2);
|
|
254
|
-
if (args.includes("--write-baseline")) {
|
|
255
|
-
const project = buildSharedProject([guard]);
|
|
256
|
-
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
257
|
-
process.exit(0);
|
|
258
|
-
}
|
|
259
|
-
if (args.includes("--no-baseline")) {
|
|
260
|
-
const project = buildSharedProject([guard]);
|
|
261
|
-
analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
|
|
262
|
-
process.exit(0);
|
|
263
|
-
}
|
|
264
|
-
runStandalone(guard);
|
|
265
|
-
}
|
|
202
|
+
if (import.meta.main) runStandalone(guard);
|
package/src/run-guards.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { guard as sectionFieldsRaw } from "./guard-section-fields-raw";
|
|
|
49
49
|
import { guard as silentSkip } from "./guard-silent-skip";
|
|
50
50
|
import { guard as tableDdl } from "./guard-table-ddl";
|
|
51
51
|
import { guard as tenantEscalation } from "./guard-tenant-escalation";
|
|
52
|
+
import { guard as testTemplateDrift } from "./guard-test-template-drift";
|
|
52
53
|
import { guard as testTimeouts } from "./guard-test-timeouts";
|
|
53
54
|
import { guard as textFieldStance } from "./guard-text-field-stance";
|
|
54
55
|
import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
|
|
@@ -90,6 +91,7 @@ export const GUARDS = [
|
|
|
90
91
|
appFeatureStructure,
|
|
91
92
|
libTestCoverage,
|
|
92
93
|
testTimeouts,
|
|
94
|
+
testTemplateDrift,
|
|
93
95
|
];
|
|
94
96
|
|
|
95
97
|
export const GUARD_FLAGS = [
|
package/src/run-repo-checks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { cliFlagsError, printGuardKitBanner, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
3
|
+
import { check as realProviderIsolation } from "./check-real-provider-isolation";
|
|
3
4
|
// Standalone-`main()` guards ported as RepoCheck — run in-process, no
|
|
4
5
|
// per-guard subprocess/project.
|
|
5
6
|
import { check as runtimeIsolation } from "./check-runtime-isolation";
|
|
@@ -24,6 +25,7 @@ export const REPO_CHECKS = [
|
|
|
24
25
|
testStackDrift,
|
|
25
26
|
runtimeIsolation,
|
|
26
27
|
upgradeState,
|
|
28
|
+
realProviderIsolation,
|
|
27
29
|
];
|
|
28
30
|
|
|
29
31
|
// No flags today — the array stays so an unknown flag still fails loud
|