@mhosaic/feedback-cli 0.20.0 → 0.22.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/dist/bin.js +6 -1
- package/dist/bin.js.map +1 -1
- package/dist/build-RCLWV2WL.js +565 -0
- package/dist/build-RCLWV2WL.js.map +1 -0
- package/dist/check-FOJPEE4Q.js +342 -0
- package/dist/check-FOJPEE4Q.js.map +1 -0
- package/dist/chunk-AZQSQJNQ.js +83 -0
- package/dist/chunk-AZQSQJNQ.js.map +1 -0
- package/dist/chunk-IHJPCMYF.js +126 -0
- package/dist/chunk-IHJPCMYF.js.map +1 -0
- package/dist/chunk-SSLQOK2Z.js +456 -0
- package/dist/chunk-SSLQOK2Z.js.map +1 -0
- package/dist/config-JE3QRZVH.js +8 -0
- package/dist/config-JE3QRZVH.js.map +1 -0
- package/dist/generate-VFRQNPOI.js +14 -0
- package/dist/generate-VFRQNPOI.js.map +1 -0
- package/dist/{install-skill-QJ4ZDVVR.js → install-skill-PO5YSXWY.js} +4 -2
- package/dist/{install-skill-QJ4ZDVVR.js.map → install-skill-PO5YSXWY.js.map} +1 -1
- package/dist/qa-YR4GCV6T.js +43 -0
- package/dist/qa-YR4GCV6T.js.map +1 -0
- package/dist/sitemap-react-QTEAUKN5.js +330 -0
- package/dist/sitemap-react-QTEAUKN5.js.map +1 -0
- package/dist/sitemap-vue-EJDQTCYM.js +243 -0
- package/dist/sitemap-vue-EJDQTCYM.js.map +1 -0
- package/package.json +4 -3
- package/skills/integrate-feedback/SKILL.md +7 -0
- package/skills/integrate-qa-meter/SKILL.md +201 -0
- package/skills/integrate-qa-meter/references/qa-meter-config.md +168 -0
- package/skills/integrate-qa-meter/references/qa-meter-troubleshooting.md +37 -0
- package/skills/integrate-qa-meter/references/qa-meter.config.example.json +20 -0
- package/skills/integrate-qa-meter/references/qa-meter.config.example.react.json +19 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/qa/config.ts
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
import { isAbsolute, join, resolve } from "path";
|
|
6
|
+
var CONFIG_NAME = "qa-meter.config.json";
|
|
7
|
+
function abs(dir, p) {
|
|
8
|
+
return isAbsolute(p) ? p : resolve(join(dir, p));
|
|
9
|
+
}
|
|
10
|
+
function reqString(raw, field) {
|
|
11
|
+
const v = raw[field];
|
|
12
|
+
if (v === void 0 || v === null) throw new Error(`${field} is required in ${CONFIG_NAME}`);
|
|
13
|
+
if (typeof v !== "string") throw new Error(`${field} must be a string in ${CONFIG_NAME}`);
|
|
14
|
+
return v;
|
|
15
|
+
}
|
|
16
|
+
function optString(raw, field, fallback) {
|
|
17
|
+
const v = raw[field];
|
|
18
|
+
if (v === void 0 || v === null) return fallback;
|
|
19
|
+
if (typeof v !== "string") throw new Error(`${field} must be a string in ${CONFIG_NAME}`);
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
function optPath(raw, field, dir) {
|
|
23
|
+
const v = raw[field];
|
|
24
|
+
if (v === void 0 || v === null) return null;
|
|
25
|
+
if (typeof v !== "string") throw new Error(`${field} must be a string in ${CONFIG_NAME}`);
|
|
26
|
+
return abs(dir, v);
|
|
27
|
+
}
|
|
28
|
+
function optAlias(raw, field, dir) {
|
|
29
|
+
const v = raw[field];
|
|
30
|
+
if (v === void 0 || v === null) return null;
|
|
31
|
+
if (typeof v !== "object" || Array.isArray(v)) {
|
|
32
|
+
throw new Error(`${field} must be an object map in ${CONFIG_NAME}`);
|
|
33
|
+
}
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [key, val] of Object.entries(v)) {
|
|
36
|
+
if (typeof val !== "string") {
|
|
37
|
+
throw new Error(`${field}.${key} must be a string in ${CONFIG_NAME}`);
|
|
38
|
+
}
|
|
39
|
+
out[key] = abs(dir, val);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function optRouterStyle(raw) {
|
|
44
|
+
const v = raw.routerStyle;
|
|
45
|
+
if (v === void 0 || v === null) return null;
|
|
46
|
+
if (v !== "jsx" && v !== "data") {
|
|
47
|
+
throw new Error(`sitemap.routerStyle must be "jsx" or "data" in ${CONFIG_NAME}`);
|
|
48
|
+
}
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
function loadQaConfig(cwd) {
|
|
52
|
+
const file = join(cwd, CONFIG_NAME);
|
|
53
|
+
if (!existsSync(file)) {
|
|
54
|
+
throw new Error(`Missing ${CONFIG_NAME} in ${cwd}. Create one \u2014 see the qa-meter design spec \xA77.1.`);
|
|
55
|
+
}
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(`Failed to parse ${CONFIG_NAME}: ${err instanceof Error ? err.message : String(err)}`);
|
|
61
|
+
}
|
|
62
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
63
|
+
throw new Error(`${CONFIG_NAME} must contain a JSON object`);
|
|
64
|
+
}
|
|
65
|
+
const raw = parsed;
|
|
66
|
+
for (const field of [
|
|
67
|
+
"e2eDir",
|
|
68
|
+
"suitesDir",
|
|
69
|
+
"outFile",
|
|
70
|
+
"testsGlob",
|
|
71
|
+
"fixturesFile",
|
|
72
|
+
"pageObjectsDir",
|
|
73
|
+
"annotationPrefix",
|
|
74
|
+
"environment",
|
|
75
|
+
"resultsFile",
|
|
76
|
+
"devValidationsFile"
|
|
77
|
+
]) {
|
|
78
|
+
const v = raw[field];
|
|
79
|
+
if (v !== void 0 && v !== null && typeof v !== "string") {
|
|
80
|
+
throw new Error(`${field} must be a string in ${CONFIG_NAME}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let sitemap = null;
|
|
84
|
+
if (raw.sitemap !== void 0 && raw.sitemap !== null) {
|
|
85
|
+
if (typeof raw.sitemap !== "object" || Array.isArray(raw.sitemap)) {
|
|
86
|
+
throw new Error(`sitemap must be an object in ${CONFIG_NAME}`);
|
|
87
|
+
}
|
|
88
|
+
const s = raw.sitemap;
|
|
89
|
+
const routerDir = optPath(s, "routerDir", cwd);
|
|
90
|
+
const titlesFrom = optPath(s, "titlesFrom", cwd);
|
|
91
|
+
const alias = optAlias(s, "alias", cwd);
|
|
92
|
+
const entry = optPath(s, "entry", cwd);
|
|
93
|
+
const routerStyle = optRouterStyle(s);
|
|
94
|
+
sitemap = {
|
|
95
|
+
file: abs(cwd, reqString(s, "file")),
|
|
96
|
+
...s.framework !== void 0 && s.framework !== null ? { framework: optString(s, "framework", "") } : {},
|
|
97
|
+
...routerDir !== null ? { routerDir } : {},
|
|
98
|
+
...titlesFrom !== null ? { titlesFrom } : {},
|
|
99
|
+
...alias !== null ? { alias } : {},
|
|
100
|
+
...entry !== null ? { entry } : {},
|
|
101
|
+
...routerStyle !== null ? { routerStyle } : {}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
e2eDir: abs(cwd, reqString(raw, "e2eDir")),
|
|
106
|
+
suitesDir: abs(cwd, reqString(raw, "suitesDir")),
|
|
107
|
+
outFile: abs(cwd, reqString(raw, "outFile")),
|
|
108
|
+
// testsGlob, fixturesFile, pageObjectsDir are kept relative to e2eDir (not absolutized).
|
|
109
|
+
testsGlob: optString(raw, "testsGlob", "tests/**/*.spec.ts"),
|
|
110
|
+
fixturesFile: optString(raw, "fixturesFile", "helpers/fixtures.ts"),
|
|
111
|
+
pageObjectsDir: optString(raw, "pageObjectsDir", "pages"),
|
|
112
|
+
annotationPrefix: optString(raw, "annotationPrefix", "@mhosaic"),
|
|
113
|
+
environment: optString(raw, "environment", "staging"),
|
|
114
|
+
appVersion: raw.appVersion === void 0 || raw.appVersion === null ? null : typeof raw.appVersion === "string" ? raw.appVersion : (() => {
|
|
115
|
+
throw new Error(`appVersion must be a string or null in ${CONFIG_NAME}`);
|
|
116
|
+
})(),
|
|
117
|
+
resultsFile: optPath(raw, "resultsFile", cwd),
|
|
118
|
+
devValidationsFile: optPath(raw, "devValidationsFile", cwd),
|
|
119
|
+
sitemap
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
loadQaConfig
|
|
125
|
+
};
|
|
126
|
+
//# sourceMappingURL=chunk-IHJPCMYF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/qa/config.ts"],"sourcesContent":["import { existsSync, readFileSync } from 'node:fs'\nimport { isAbsolute, join, resolve } from 'node:path'\n\n/**\n * Optional sitemap block of the qa-meter config. When absent the meter runs in\n * degraded flat mode (no route rollups). Path fields are absolutized against\n * the config file's directory; `framework` is an adapter name (no path).\n */\nexport interface QaSitemapConfig {\n /** Where the sitemap.json lives (output of `qa sitemap`, or hand-provided). Absolute. */\n file: string\n /** Adapter name for `qa sitemap` (e.g. 'vue-router'); omitted if hand-provided. */\n framework?: string\n /** Directory holding the router declarations. Absolute. */\n routerDir?: string\n /** Optional locale JSON for human titles. Absolute. */\n titlesFrom?: string\n /**\n * Optional import-alias map for the adapter (e.g. `{ '@': 'src' }`). KEYS are\n * kept verbatim; VALUES are absolutized against the config dir (like other\n * path fields) so `@/router/x` resolves to a real file. Default: none.\n */\n alias?: Record<string, string>\n /**\n * react-router adapter only: the file holding the route tree (e.g.\n * `frontend/src/App.tsx` or `frontend/src/router.tsx`). Absolute.\n */\n entry?: string\n /** react-router adapter only: force a reader instead of auto-detecting. */\n routerStyle?: 'jsx' | 'data'\n}\n\n/**\n * Resolved qa-meter config. All filesystem paths are ABSOLUTE (resolved against\n * the directory containing qa-meter.config.json) except `testsGlob` and\n * `fixturesFile`/`pageObjectsDir`, which stay relative to `e2eDir` — they are\n * consumed downstream by joining onto `e2eDir`, so they are kept as-given.\n */\nexport interface QaConfig {\n /** Playwright e2e root. Absolute. */\n e2eDir: string\n /** Suites root ('authored' + 'generated/'). Absolute. */\n suitesDir: string\n /** Where the assembled artifact is written. Absolute. */\n outFile: string\n /** Glob for spec files, relative to e2eDir (kept as a glob string). */\n testsGlob: string\n /** Fixtures file, relative to e2eDir (kept as-given). */\n fixturesFile: string\n /** Page-objects dir, relative to e2eDir (kept as-given). */\n pageObjectsDir: string\n /** Annotation prefix honored in spec headers. */\n annotationPrefix: string\n /** Environment stamped into the artifact. */\n environment: string\n /** App version string, or null (resolved from --version flag downstream). */\n appVersion: string | null\n /** Optional Playwright results file. Absolute, or null. */\n resultsFile: string | null\n /** Optional dev-validations file. Absolute, or null. */\n devValidationsFile: string | null\n /** Optional sitemap block, or null when absent. */\n sitemap: QaSitemapConfig | null\n}\n\nconst CONFIG_NAME = 'qa-meter.config.json'\n\n/** Absolutize `p` against `dir` (already-absolute paths pass through). */\nfunction abs(dir: string, p: string): string {\n return isAbsolute(p) ? p : resolve(join(dir, p))\n}\n\n/** Read a required string field; throw a field-named error if missing/wrong-type. */\nfunction reqString(raw: Record<string, unknown>, field: string): string {\n const v = raw[field]\n if (v === undefined || v === null) throw new Error(`${field} is required in ${CONFIG_NAME}`)\n if (typeof v !== 'string') throw new Error(`${field} must be a string in ${CONFIG_NAME}`)\n return v\n}\n\n/** Read an optional string field with a default; throw a field-named error on wrong type. */\nfunction optString(raw: Record<string, unknown>, field: string, fallback: string): string {\n const v = raw[field]\n if (v === undefined || v === null) return fallback\n if (typeof v !== 'string') throw new Error(`${field} must be a string in ${CONFIG_NAME}`)\n return v\n}\n\n/** Read an optional path field; absolutize if present, else null. Throws on wrong type. */\nfunction optPath(raw: Record<string, unknown>, field: string, dir: string): string | null {\n const v = raw[field]\n if (v === undefined || v === null) return null\n if (typeof v !== 'string') throw new Error(`${field} must be a string in ${CONFIG_NAME}`)\n return abs(dir, v)\n}\n\n/**\n * Read an optional alias map field. KEYS pass through verbatim; VALUES are\n * absolutized against `dir` (like other path fields) so `@` → `<dir>/src`\n * becomes an absolute base for the adapter. Returns null if absent. Throws a\n * field-named error on wrong shape.\n */\nfunction optAlias(\n raw: Record<string, unknown>,\n field: string,\n dir: string,\n): Record<string, string> | null {\n const v = raw[field]\n if (v === undefined || v === null) return null\n if (typeof v !== 'object' || Array.isArray(v)) {\n throw new Error(`${field} must be an object map in ${CONFIG_NAME}`)\n }\n const out: Record<string, string> = {}\n for (const [key, val] of Object.entries(v as Record<string, unknown>)) {\n if (typeof val !== 'string') {\n throw new Error(`${field}.${key} must be a string in ${CONFIG_NAME}`)\n }\n out[key] = abs(dir, val)\n }\n return out\n}\n\n/** Read an optional `routerStyle` ('jsx' | 'data'); null if absent. Throws on bad value. */\nfunction optRouterStyle(raw: Record<string, unknown>): 'jsx' | 'data' | null {\n const v = raw.routerStyle\n if (v === undefined || v === null) return null\n if (v !== 'jsx' && v !== 'data') {\n throw new Error(`sitemap.routerStyle must be \"jsx\" or \"data\" in ${CONFIG_NAME}`)\n }\n return v\n}\n\n/**\n * Load and validate `qa-meter.config.json` from `cwd`. All paths are resolved\n * against `cwd` (the directory containing the config file). Validation is\n * hand-rolled (no schema lib) to match the CLI's zero-dependency posture.\n */\nexport function loadQaConfig(cwd: string): QaConfig {\n const file = join(cwd, CONFIG_NAME)\n if (!existsSync(file)) {\n throw new Error(`Missing ${CONFIG_NAME} in ${cwd}. Create one — see the qa-meter design spec §7.1.`)\n }\n\n let parsed: unknown\n try {\n parsed = JSON.parse(readFileSync(file, 'utf8'))\n } catch (err) {\n throw new Error(`Failed to parse ${CONFIG_NAME}: ${err instanceof Error ? err.message : String(err)}`)\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n throw new Error(`${CONFIG_NAME} must contain a JSON object`)\n }\n const raw = parsed as Record<string, unknown>\n\n // Type-check every present-but-wrong-type string field up front, so a bad\n // field surfaces a field-named error regardless of whether some *other*\n // required field happens to be missing.\n for (const field of [\n 'e2eDir',\n 'suitesDir',\n 'outFile',\n 'testsGlob',\n 'fixturesFile',\n 'pageObjectsDir',\n 'annotationPrefix',\n 'environment',\n 'resultsFile',\n 'devValidationsFile',\n ]) {\n const v = raw[field]\n if (v !== undefined && v !== null && typeof v !== 'string') {\n throw new Error(`${field} must be a string in ${CONFIG_NAME}`)\n }\n }\n\n let sitemap: QaSitemapConfig | null = null\n if (raw.sitemap !== undefined && raw.sitemap !== null) {\n if (typeof raw.sitemap !== 'object' || Array.isArray(raw.sitemap)) {\n throw new Error(`sitemap must be an object in ${CONFIG_NAME}`)\n }\n const s = raw.sitemap as Record<string, unknown>\n const routerDir = optPath(s, 'routerDir', cwd)\n const titlesFrom = optPath(s, 'titlesFrom', cwd)\n const alias = optAlias(s, 'alias', cwd)\n const entry = optPath(s, 'entry', cwd)\n const routerStyle = optRouterStyle(s)\n sitemap = {\n file: abs(cwd, reqString(s, 'file')),\n ...(s.framework !== undefined && s.framework !== null\n ? { framework: optString(s, 'framework', '') }\n : {}),\n ...(routerDir !== null ? { routerDir } : {}),\n ...(titlesFrom !== null ? { titlesFrom } : {}),\n ...(alias !== null ? { alias } : {}),\n ...(entry !== null ? { entry } : {}),\n ...(routerStyle !== null ? { routerStyle } : {}),\n }\n }\n\n return {\n e2eDir: abs(cwd, reqString(raw, 'e2eDir')),\n suitesDir: abs(cwd, reqString(raw, 'suitesDir')),\n outFile: abs(cwd, reqString(raw, 'outFile')),\n // testsGlob, fixturesFile, pageObjectsDir are kept relative to e2eDir (not absolutized).\n testsGlob: optString(raw, 'testsGlob', 'tests/**/*.spec.ts'),\n fixturesFile: optString(raw, 'fixturesFile', 'helpers/fixtures.ts'),\n pageObjectsDir: optString(raw, 'pageObjectsDir', 'pages'),\n annotationPrefix: optString(raw, 'annotationPrefix', '@mhosaic'),\n environment: optString(raw, 'environment', 'staging'),\n appVersion:\n raw.appVersion === undefined || raw.appVersion === null\n ? null\n : typeof raw.appVersion === 'string'\n ? raw.appVersion\n : (() => {\n throw new Error(`appVersion must be a string or null in ${CONFIG_NAME}`)\n })(),\n resultsFile: optPath(raw, 'resultsFile', cwd),\n devValidationsFile: optPath(raw, 'devValidationsFile', cwd),\n sitemap,\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
normalizePattern
|
|
4
|
+
} from "./chunk-AZQSQJNQ.js";
|
|
5
|
+
import {
|
|
6
|
+
loadQaConfig
|
|
7
|
+
} from "./chunk-IHJPCMYF.js";
|
|
8
|
+
|
|
9
|
+
// src/qa/generate.ts
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
11
|
+
import { dirname, join, relative, resolve } from "path";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
import ts from "typescript";
|
|
14
|
+
import { stringify } from "yaml";
|
|
15
|
+
var HEADER_COMMENT = "# GENERATED by qa-meter \u2014 do not hand-edit. Regenerate with: mhosaic-feedback qa generate";
|
|
16
|
+
function cliVersion() {
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
for (let dir = here, i = 0; i < 5; i++, dir = dirname(dir)) {
|
|
19
|
+
const candidate = join(dir, "package.json");
|
|
20
|
+
if (existsSync(candidate)) {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf8"));
|
|
23
|
+
if (pkg.name === "@mhosaic/feedback-cli" && typeof pkg.version === "string") return pkg.version;
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return "0.0.0";
|
|
29
|
+
}
|
|
30
|
+
function slug(input) {
|
|
31
|
+
return input.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
32
|
+
}
|
|
33
|
+
function patternKey(pattern) {
|
|
34
|
+
const key = pattern.replace(/[:*]/g, "").split("/").filter(Boolean).map((seg) => slug(seg)).filter(Boolean).join("-");
|
|
35
|
+
return key.length > 0 ? key : "root";
|
|
36
|
+
}
|
|
37
|
+
function parseFile(file) {
|
|
38
|
+
const text = readFileSync(file, "utf8");
|
|
39
|
+
return ts.createSourceFile(
|
|
40
|
+
file,
|
|
41
|
+
text,
|
|
42
|
+
ts.ScriptTarget.Latest,
|
|
43
|
+
/*setParentNodes*/
|
|
44
|
+
true,
|
|
45
|
+
ts.ScriptKind.TS
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
function resolveImport(fromFile, spec) {
|
|
49
|
+
if (!spec.startsWith(".")) return null;
|
|
50
|
+
const base = resolve(dirname(fromFile), spec);
|
|
51
|
+
for (const candidate of [`${base}.ts`, join(base, "index.ts")]) {
|
|
52
|
+
if (existsSync(candidate)) return candidate;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function importMap(sf) {
|
|
57
|
+
const map = /* @__PURE__ */ new Map();
|
|
58
|
+
for (const stmt of sf.statements) {
|
|
59
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
60
|
+
const target = resolveImport(sf.fileName, stmt.moduleSpecifier.text);
|
|
61
|
+
if (!target) continue;
|
|
62
|
+
const bindings = stmt.importClause?.namedBindings;
|
|
63
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
64
|
+
for (const el of bindings.elements) map.set(el.name.text, target);
|
|
65
|
+
}
|
|
66
|
+
if (stmt.importClause?.name) map.set(stmt.importClause.name.text, target);
|
|
67
|
+
}
|
|
68
|
+
return map;
|
|
69
|
+
}
|
|
70
|
+
function fixtureClasses(sf) {
|
|
71
|
+
const out = /* @__PURE__ */ new Map();
|
|
72
|
+
const isExtendCall = (node) => ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "extend";
|
|
73
|
+
let objLit;
|
|
74
|
+
const findExtend = (node) => {
|
|
75
|
+
if (objLit) return;
|
|
76
|
+
if (isExtendCall(node)) {
|
|
77
|
+
const arg = node.arguments[0];
|
|
78
|
+
if (arg && ts.isObjectLiteralExpression(arg)) objLit = arg;
|
|
79
|
+
}
|
|
80
|
+
ts.forEachChild(node, findExtend);
|
|
81
|
+
};
|
|
82
|
+
findExtend(sf);
|
|
83
|
+
if (!objLit) return out;
|
|
84
|
+
for (const prop of objLit.properties) {
|
|
85
|
+
let name;
|
|
86
|
+
let body;
|
|
87
|
+
if (ts.isMethodDeclaration(prop) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) {
|
|
88
|
+
name = prop.name.text;
|
|
89
|
+
body = prop.body;
|
|
90
|
+
} else if (ts.isPropertyAssignment(prop) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) && (ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
|
|
91
|
+
name = prop.name.text;
|
|
92
|
+
body = prop.initializer.body;
|
|
93
|
+
}
|
|
94
|
+
if (!name || !body) continue;
|
|
95
|
+
let className;
|
|
96
|
+
const findUse = (node) => {
|
|
97
|
+
if (className) return;
|
|
98
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "use" && node.arguments[0] && ts.isNewExpression(node.arguments[0]) && ts.isIdentifier(node.arguments[0].expression)) {
|
|
99
|
+
className = node.arguments[0].expression.text;
|
|
100
|
+
}
|
|
101
|
+
ts.forEachChild(node, findUse);
|
|
102
|
+
};
|
|
103
|
+
findUse(body);
|
|
104
|
+
if (className) out.set(name, className);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function gotoPattern(sf, className) {
|
|
109
|
+
let cls;
|
|
110
|
+
for (const stmt of sf.statements) {
|
|
111
|
+
if (ts.isClassDeclaration(stmt) && stmt.name?.text === className) cls = stmt;
|
|
112
|
+
}
|
|
113
|
+
if (!cls) return null;
|
|
114
|
+
let method;
|
|
115
|
+
for (const member of cls.members) {
|
|
116
|
+
if (ts.isMethodDeclaration(member) && ts.isIdentifier(member.name) && member.name.text === "goto") {
|
|
117
|
+
method = member;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!method?.body) return null;
|
|
121
|
+
const params = method.parameters.map((p) => ts.isIdentifier(p.name) ? p.name.text : void 0).filter((n) => !!n);
|
|
122
|
+
let arg;
|
|
123
|
+
const findGoto = (node) => {
|
|
124
|
+
if (arg) return;
|
|
125
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "goto" && node.arguments.length > 0) {
|
|
126
|
+
arg = node.arguments[0];
|
|
127
|
+
}
|
|
128
|
+
ts.forEachChild(node, findGoto);
|
|
129
|
+
};
|
|
130
|
+
findGoto(method.body);
|
|
131
|
+
if (!arg) return null;
|
|
132
|
+
if (ts.isStringLiteralLike(arg)) return normalizePattern(arg.text);
|
|
133
|
+
if (ts.isTemplateExpression(arg)) {
|
|
134
|
+
let raw = arg.head.text;
|
|
135
|
+
arg.templateSpans.forEach((span, i) => {
|
|
136
|
+
let name;
|
|
137
|
+
if (ts.isIdentifier(span.expression)) {
|
|
138
|
+
name = span.expression.text;
|
|
139
|
+
} else {
|
|
140
|
+
name = params[i] ?? "param";
|
|
141
|
+
}
|
|
142
|
+
raw += `:${name}${span.literal.text}`;
|
|
143
|
+
});
|
|
144
|
+
return normalizePattern(raw);
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
function parseHeader(text, prefix) {
|
|
149
|
+
const out = {};
|
|
150
|
+
const lines = text.split(/\r?\n/);
|
|
151
|
+
const comments = [];
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (trimmed === "") continue;
|
|
155
|
+
if (trimmed.startsWith("//")) {
|
|
156
|
+
comments.push(trimmed.replace(/^\/\/\s?/, ""));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (trimmed.startsWith("/*") || trimmed.startsWith("*")) {
|
|
160
|
+
comments.push(trimmed.replace(/^\/\*+\s?/, "").replace(/^\*+\s?/, "").replace(/\*+\/\s*$/, ""));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
166
|
+
const read = (pfx) => {
|
|
167
|
+
for (const c of comments) {
|
|
168
|
+
const pagesMatch = c.match(new RegExp(`${pfx}:pages\\s+(.+)$`));
|
|
169
|
+
if (pagesMatch?.[1] && out.pages === void 0) {
|
|
170
|
+
out.pages = pagesMatch[1].split(",").map((p) => p.trim()).filter(Boolean);
|
|
171
|
+
}
|
|
172
|
+
const scenarioMatch = c.match(new RegExp(`${pfx}:scenario\\s+(\\S+)`));
|
|
173
|
+
if (scenarioMatch?.[1] && out.scenario === void 0) out.scenario = scenarioMatch[1];
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
read(escaped);
|
|
177
|
+
read("@testgen");
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function destructuredKeys(fn) {
|
|
181
|
+
const param = fn.parameters[0];
|
|
182
|
+
if (!param || !ts.isObjectBindingPattern(param.name)) return [];
|
|
183
|
+
const keys = [];
|
|
184
|
+
for (const el of param.name.elements) {
|
|
185
|
+
const keyNode = el.propertyName ?? el.name;
|
|
186
|
+
if (ts.isIdentifier(keyNode)) keys.push(keyNode.text);
|
|
187
|
+
else if (ts.isStringLiteral(keyNode)) keys.push(keyNode.text);
|
|
188
|
+
}
|
|
189
|
+
return keys;
|
|
190
|
+
}
|
|
191
|
+
function literalGotos(body) {
|
|
192
|
+
const out = [];
|
|
193
|
+
const visit = (node) => {
|
|
194
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "goto" && node.arguments[0] && ts.isStringLiteralLike(node.arguments[0])) {
|
|
195
|
+
out.push(normalizePattern(node.arguments[0].text));
|
|
196
|
+
}
|
|
197
|
+
ts.forEachChild(node, visit);
|
|
198
|
+
};
|
|
199
|
+
visit(body);
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
function isTestCall(node) {
|
|
203
|
+
const callee = node.expression;
|
|
204
|
+
if (ts.isIdentifier(callee)) return callee.text === "test";
|
|
205
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "test") {
|
|
206
|
+
return callee.name.text === "only" || callee.name.text === "skip" || callee.name.text === "fixme";
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
function specCases(sf) {
|
|
211
|
+
const cases = [];
|
|
212
|
+
const dynamicTitles = [];
|
|
213
|
+
const visit = (node) => {
|
|
214
|
+
if (ts.isCallExpression(node) && isTestCall(node)) {
|
|
215
|
+
const titleArg = node.arguments[0];
|
|
216
|
+
const fnArg = node.arguments[1];
|
|
217
|
+
if (titleArg && ts.isStringLiteralLike(titleArg)) {
|
|
218
|
+
if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) {
|
|
219
|
+
const fixtures = destructuredKeys(fnArg);
|
|
220
|
+
const gotos = fnArg.body ? literalGotos(fnArg.body) : [];
|
|
221
|
+
cases.push({ title: titleArg.text, fixtures, gotos });
|
|
222
|
+
} else {
|
|
223
|
+
cases.push({ title: titleArg.text, fixtures: [], gotos: [] });
|
|
224
|
+
}
|
|
225
|
+
} else if (titleArg) {
|
|
226
|
+
dynamicTitles.push(approxTitle(titleArg));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
ts.forEachChild(node, visit);
|
|
230
|
+
};
|
|
231
|
+
visit(sf);
|
|
232
|
+
dynamicTitles.sort((a, b) => a.localeCompare(b));
|
|
233
|
+
return { cases, dynamicTitles };
|
|
234
|
+
}
|
|
235
|
+
function approxTitle(node) {
|
|
236
|
+
const text = node.getText().replace(/\s+/g, " ").trim();
|
|
237
|
+
const kind = ts.isTemplateExpression(node) ? "template literal" : ts.isIdentifier(node) ? "identifier" : "non-static";
|
|
238
|
+
return `${text.slice(0, 80)} (${kind} title)`;
|
|
239
|
+
}
|
|
240
|
+
function specFiles(e2eDir, testsGlob) {
|
|
241
|
+
const starIdx = testsGlob.indexOf("**");
|
|
242
|
+
const baseRel = starIdx >= 0 ? testsGlob.slice(0, starIdx).replace(/\/+$/, "") : dirname(testsGlob);
|
|
243
|
+
const suffixMatch = testsGlob.match(/\*([^*/]+)$/);
|
|
244
|
+
const suffix = suffixMatch?.[1] ?? ".spec.ts";
|
|
245
|
+
const baseAbs = join(e2eDir, baseRel);
|
|
246
|
+
const out = [];
|
|
247
|
+
const walk = (dir) => {
|
|
248
|
+
let entries;
|
|
249
|
+
try {
|
|
250
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
|
|
251
|
+
} catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
255
|
+
const full = join(dir, entry.name);
|
|
256
|
+
if (entry.isDirectory()) walk(full);
|
|
257
|
+
else if (entry.name.endsWith(suffix)) out.push(full);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
walk(baseAbs);
|
|
261
|
+
return out.sort((a, b) => a.localeCompare(b));
|
|
262
|
+
}
|
|
263
|
+
function generateSuites(cfg) {
|
|
264
|
+
const version = cliVersion();
|
|
265
|
+
const e2eDir = cfg.e2eDir;
|
|
266
|
+
const testsBase = (() => {
|
|
267
|
+
const starIdx = cfg.testsGlob.indexOf("**");
|
|
268
|
+
return starIdx >= 0 ? cfg.testsGlob.slice(0, starIdx).replace(/\/+$/, "") : dirname(cfg.testsGlob);
|
|
269
|
+
})();
|
|
270
|
+
const fixtureToFile = /* @__PURE__ */ new Map();
|
|
271
|
+
const fixturesAbs = join(e2eDir, cfg.fixturesFile);
|
|
272
|
+
if (existsSync(fixturesAbs)) {
|
|
273
|
+
const sf = parseFile(fixturesAbs);
|
|
274
|
+
const imports = importMap(sf);
|
|
275
|
+
for (const [fixtureName, className] of fixtureClasses(sf)) {
|
|
276
|
+
const target = imports.get(className);
|
|
277
|
+
if (target) fixtureToFile.set(fixtureName, target);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const fixtureClassMap = existsSync(fixturesAbs) ? fixtureClasses(parseFile(fixturesAbs)) : /* @__PURE__ */ new Map();
|
|
281
|
+
const patternByFile = /* @__PURE__ */ new Map();
|
|
282
|
+
const fixtureToPattern = /* @__PURE__ */ new Map();
|
|
283
|
+
for (const [fixtureName, file] of fixtureToFile) {
|
|
284
|
+
const className = fixtureClassMap.get(fixtureName);
|
|
285
|
+
if (!className || !existsSync(file)) continue;
|
|
286
|
+
let sf = patternByFile.get(file);
|
|
287
|
+
if (!sf) {
|
|
288
|
+
sf = parseFile(file);
|
|
289
|
+
patternByFile.set(file, sf);
|
|
290
|
+
}
|
|
291
|
+
const pattern = gotoPattern(sf, className);
|
|
292
|
+
if (pattern) fixtureToPattern.set(fixtureName, pattern);
|
|
293
|
+
}
|
|
294
|
+
const unmappable = [];
|
|
295
|
+
const dynamicTitles = [];
|
|
296
|
+
const zeroCaseSpecs = [];
|
|
297
|
+
const sections = /* @__PURE__ */ new Map();
|
|
298
|
+
const files = specFiles(e2eDir, cfg.testsGlob);
|
|
299
|
+
for (const file of files) {
|
|
300
|
+
const specRel = relative(e2eDir, file).split("\\").join("/");
|
|
301
|
+
const specBasename = basenameNoSpec(specRel);
|
|
302
|
+
const section = sectionFor(specRel, testsBase);
|
|
303
|
+
const text = readFileSync(file, "utf8");
|
|
304
|
+
const header = parseHeader(text, cfg.annotationPrefix);
|
|
305
|
+
const sf = parseFile(file);
|
|
306
|
+
const scan = specCases(sf);
|
|
307
|
+
const cases = scan.cases;
|
|
308
|
+
for (const approx of scan.dynamicTitles) dynamicTitles.push(`${specRel} :: ${approx}`);
|
|
309
|
+
if (cases.length === 0) zeroCaseSpecs.push(specRel);
|
|
310
|
+
if (!sections.has(section)) sections.set(section, { cases: [], pages: /* @__PURE__ */ new Map() });
|
|
311
|
+
const sec = sections.get(section);
|
|
312
|
+
cases.forEach((spec, index) => {
|
|
313
|
+
let primary;
|
|
314
|
+
let traversed = [];
|
|
315
|
+
let pagesSource = "inferred";
|
|
316
|
+
if (header.pages && header.pages.length > 0) {
|
|
317
|
+
const normalized = header.pages.map((p) => normalizePattern(p));
|
|
318
|
+
primary = normalized[0];
|
|
319
|
+
traversed = normalized.slice(1);
|
|
320
|
+
pagesSource = "annotation";
|
|
321
|
+
} else {
|
|
322
|
+
const inferred = [];
|
|
323
|
+
for (const fx of spec.fixtures) {
|
|
324
|
+
const pat = fixtureToPattern.get(fx);
|
|
325
|
+
if (pat) inferred.push(pat);
|
|
326
|
+
}
|
|
327
|
+
for (const g of spec.gotos) inferred.push(g);
|
|
328
|
+
if (inferred.length > 0) {
|
|
329
|
+
primary = inferred[0];
|
|
330
|
+
traversed = inferred.slice(1);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const externalKey = header.scenario ?? `${specRel}::${slug(spec.title)}`;
|
|
334
|
+
let appliesTo = [];
|
|
335
|
+
if (primary) {
|
|
336
|
+
const key = registerPage(sec.pages, primary, specRel, spec.title);
|
|
337
|
+
appliesTo = [key];
|
|
338
|
+
} else {
|
|
339
|
+
unmappable.push(`${specRel} :: ${spec.title}`);
|
|
340
|
+
}
|
|
341
|
+
const testCase = {
|
|
342
|
+
id: `${specBasename}-${index}`,
|
|
343
|
+
title: spec.title,
|
|
344
|
+
suite: specBasename,
|
|
345
|
+
status: "covered",
|
|
346
|
+
automation: { type: "playwright", spec: specRel, test_title: spec.title },
|
|
347
|
+
applies_to: appliesTo,
|
|
348
|
+
...traversed.length > 0 ? { traverses: traversed.map((pattern) => ({ pattern })) } : {},
|
|
349
|
+
external_key: externalKey,
|
|
350
|
+
pages_source: pagesSource
|
|
351
|
+
};
|
|
352
|
+
sec.cases.push(testCase);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const suites = /* @__PURE__ */ new Map();
|
|
356
|
+
for (const section of [...sections.keys()].sort((a, b) => a.localeCompare(b))) {
|
|
357
|
+
const sec = sections.get(section);
|
|
358
|
+
if (sec.cases.length === 0) continue;
|
|
359
|
+
const pages = {};
|
|
360
|
+
for (const key of [...sec.pages.keys()].sort((a, b) => a.localeCompare(b))) {
|
|
361
|
+
pages[key] = sec.pages.get(key);
|
|
362
|
+
}
|
|
363
|
+
const test_cases = [...sec.cases].sort((a, b) => {
|
|
364
|
+
const specA = a.automation?.spec ?? "";
|
|
365
|
+
const specB = b.automation?.spec ?? "";
|
|
366
|
+
if (specA !== specB) return specA.localeCompare(specB);
|
|
367
|
+
return a.id.localeCompare(b.id);
|
|
368
|
+
});
|
|
369
|
+
suites.set(section, {
|
|
370
|
+
meta: {
|
|
371
|
+
section,
|
|
372
|
+
format: 1,
|
|
373
|
+
origin: "generated",
|
|
374
|
+
generated_by: `qa-meter@${version}`
|
|
375
|
+
},
|
|
376
|
+
pages,
|
|
377
|
+
test_cases
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
suites,
|
|
382
|
+
unmappable,
|
|
383
|
+
dynamicTitles: dynamicTitles.sort((a, b) => a.localeCompare(b)),
|
|
384
|
+
zeroCaseSpecs: zeroCaseSpecs.sort((a, b) => a.localeCompare(b))
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function registerPage(registry, pattern, spec, testTitle) {
|
|
388
|
+
const key = patternKey(pattern);
|
|
389
|
+
if (!registry.has(key)) {
|
|
390
|
+
registry.set(key, { pattern, spec, test_title: testTitle });
|
|
391
|
+
}
|
|
392
|
+
return key;
|
|
393
|
+
}
|
|
394
|
+
function basenameNoSpec(specRel) {
|
|
395
|
+
const base = specRel.split("/").pop() ?? specRel;
|
|
396
|
+
return base.replace(/\.spec\.ts$/, "").replace(/\.ts$/, "");
|
|
397
|
+
}
|
|
398
|
+
function sectionFor(specRel, testsBase) {
|
|
399
|
+
let rel = specRel;
|
|
400
|
+
const prefix = `${testsBase}/`;
|
|
401
|
+
if (rel.startsWith(prefix)) rel = rel.slice(prefix.length);
|
|
402
|
+
const parts = rel.split("/");
|
|
403
|
+
if (parts.length > 1 && parts[0]) return parts[0];
|
|
404
|
+
return testsBase.split("/").filter(Boolean).pop() ?? "tests";
|
|
405
|
+
}
|
|
406
|
+
function serializeSuite(doc) {
|
|
407
|
+
const yaml = stringify(doc, { lineWidth: 0 });
|
|
408
|
+
return `${HEADER_COMMENT}
|
|
409
|
+
${yaml}`;
|
|
410
|
+
}
|
|
411
|
+
async function runGenerate(_args) {
|
|
412
|
+
const cfg = loadQaConfig(process.cwd());
|
|
413
|
+
const { suites, unmappable, dynamicTitles, zeroCaseSpecs } = generateSuites(cfg);
|
|
414
|
+
const outDir = join(cfg.suitesDir, "generated");
|
|
415
|
+
mkdirSync(outDir, { recursive: true });
|
|
416
|
+
const sections = [...suites.keys()].sort((a, b) => a.localeCompare(b));
|
|
417
|
+
for (const section of sections) {
|
|
418
|
+
const doc = suites.get(section);
|
|
419
|
+
const file = join(outDir, `${section}-test-suite.yaml`);
|
|
420
|
+
writeFileSync(file, serializeSuite(doc), "utf8");
|
|
421
|
+
}
|
|
422
|
+
const totalCases = sections.reduce((n, s) => n + suites.get(s).test_cases.length, 0);
|
|
423
|
+
process.stderr.write(
|
|
424
|
+
`qa generate: ${sections.length} suite(s), ${totalCases} case(s) \u2192 ${relative(process.cwd(), outDir) || outDir}
|
|
425
|
+
`
|
|
426
|
+
);
|
|
427
|
+
if (unmappable.length > 0) {
|
|
428
|
+
process.stderr.write(`qa generate: ${unmappable.length} unmappable test(s):
|
|
429
|
+
`);
|
|
430
|
+
for (const entry of unmappable) process.stderr.write(` warning: unmappable \u2014 ${entry}
|
|
431
|
+
`);
|
|
432
|
+
}
|
|
433
|
+
if (zeroCaseSpecs.length > 0) {
|
|
434
|
+
process.stderr.write(
|
|
435
|
+
`qa generate: ${zeroCaseSpecs.length} spec(s) produced no extractable cases (factory/loop/dynamic-title) \u2014 add @mhosaic:pages/@mhosaic:scenario or a string title:
|
|
436
|
+
`
|
|
437
|
+
);
|
|
438
|
+
for (const spec of zeroCaseSpecs) process.stderr.write(` warning: zero-case spec \u2014 ${spec}
|
|
439
|
+
`);
|
|
440
|
+
}
|
|
441
|
+
if (dynamicTitles.length > 0) {
|
|
442
|
+
process.stderr.write(
|
|
443
|
+
`qa generate: ${dynamicTitles.length} test(s) skipped for a non-static title (give them a string literal title or a @mhosaic:scenario id):
|
|
444
|
+
`
|
|
445
|
+
);
|
|
446
|
+
for (const entry of dynamicTitles) process.stderr.write(` warning: dynamic-title \u2014 ${entry}
|
|
447
|
+
`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export {
|
|
452
|
+
generateSuites,
|
|
453
|
+
serializeSuite,
|
|
454
|
+
runGenerate
|
|
455
|
+
};
|
|
456
|
+
//# sourceMappingURL=chunk-SSLQOK2Z.js.map
|