@sungen/driver-mock 3.2.19

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 ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@sungen/driver-mock",
3
+ "version": "3.2.19",
4
+ "description": "Sungen Mock capability (Network Mocking) — the @mock tag, mocks.yaml catalog, mock-install/call-count steps, and the specs/mock.ts runtime template. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "sungen": {
8
+ "capability": "mock"
9
+ },
10
+ "scripts": {
11
+ "build": "rm -rf dist && tsc -p tsconfig.json"
12
+ },
13
+ "author": "eqe team (engineer & quality) — Sun Asterisk",
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "@sun-asterisk/sungen": "3.2.19"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ]
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @sungen/driver-mock — Sungen Mock capability (network mocking).
3
+ *
4
+ * Owns the `@mock` tag family: the mocks.yaml catalog (self-contained named
5
+ * responses), the mock-install / call-count step patterns, and the specs/mock.ts
6
+ * runtime template (module singleton + WeakMap<Page, MockState> — state follows
7
+ * page lifetime on both the per-test and @serial paths). Plugs into core via the
8
+ * capability SPI; core discovers and calls `register()` at runtime.
9
+ *
10
+ * Blocking posture (spec: sungen_mock_driver_spec.md §5): once a scenario has any
11
+ * active mock, a catch-all route fails the test on any unmatched fetch/XHR/websocket
12
+ * request (fail-on-unmocked, the locked default). `@mock:passthrough` on a scenario
13
+ * opts out — named mocks still apply, the rest reaches the real backend.
14
+ */
15
+ import type { CapabilityRegistry } from '@sun-asterisk/sungen';
16
+ import { mockPatterns, isMockStep } from './patterns/mock-patterns';
17
+
18
+ /**
19
+ * `@mock:passthrough` → relax the catch-all for this scenario. Emitted as a
20
+ * precondition so the flag is set before any request fires; the plain `@mock`
21
+ * tag needs no precondition (install steps carry the behavior).
22
+ */
23
+ function mockPreconditionCodegen(input: { tags: string[]; screenName: string; cwd: string }): Array<{ comment?: string; code: string; boundVars?: string[] }> {
24
+ if (!input.tags.some((t) => t === '@mock:passthrough')) return [];
25
+ return [{
26
+ comment: '@mock:passthrough — unmatched requests reach the real backend for this scenario',
27
+ code: 'mock.setPassthrough(page);',
28
+ }];
29
+ }
30
+
31
+ /** Register the Mock capability. */
32
+ export function register(registry: CapabilityRegistry): void {
33
+ registry.register({
34
+ id: 'mock',
35
+ annotations: ['@mock', '@network'],
36
+ patterns: [...mockPatterns],
37
+ runtimeHelpers: [{ file: 'mock.ts', template: 'specs-mock.ts' }], // template resolved from core's templates dir
38
+ detectsStep: isMockStep, // mock steps activate the capability even without a tag
39
+ preconditionCodegen: mockPreconditionCodegen,
40
+ });
41
+ }
42
+
43
+ export const sungenDriver = { capability: 'mock', register } as const;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * mocks.yaml catalog loader — `qa/screens/<name>/mock/mocks.yaml`.
3
+ *
4
+ * Every entry is a named, self-contained response definition (no api_ref
5
+ * indirection — mocks are reviewable and secret-scannable in one file):
6
+ *
7
+ * reservations-check-500:
8
+ * method: POST # required — HTTP verb or "*"
9
+ * url: "**\/api/reservations/check" # required — page.route glob; "re:" prefix = regex
10
+ * status: 500 # required
11
+ * headers: { content-type: application/json } # optional
12
+ * body: { message: "Internal Server Error" } # optional — inline, or a "{{var}}" test-data ref
13
+ * delay_ms: 5000 # optional — hold the response N ms (the timeout simulator)
14
+ *
15
+ * The loader ERRORS on an incomplete or unknown-keyed entry, naming the entry and
16
+ * the valid keys — a malformed mock must never silently vanish from the suite
17
+ * (the api-catalog loader's silent `continue` is a known trap, deliberately not
18
+ * copied). There is no `abort` on purpose: route.abort() is a network error
19
+ * (ERR_FAILED), not a timeout — a mock named "timeout" that aborts makes tests
20
+ * green for the wrong reason. mocks.yaml is COMMITTED: credentials in bodies must
21
+ * be `${QA_*}` refs (the secret scan covers mock/*.yaml).
22
+ */
23
+
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+ import { parse as parseYaml } from 'yaml';
27
+
28
+ export interface MockEntry {
29
+ name: string;
30
+ method: string;
31
+ url: string;
32
+ status: number;
33
+ headers?: Record<string, string>;
34
+ body?: unknown;
35
+ delay_ms?: number;
36
+ }
37
+
38
+ const VALID_KEYS = ['method', 'url', 'status', 'headers', 'body', 'delay_ms'];
39
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*'];
40
+
41
+ /** Absolute path of a unit's mock catalog (screens only in MVP — the locked unit shape). */
42
+ export function mockCatalogPath(screenName: string, cwd: string = process.cwd()): string {
43
+ return path.join(cwd, 'qa', 'screens', screenName, 'mock', 'mocks.yaml');
44
+ }
45
+
46
+ /** Load and validate the unit's catalog. Missing file → empty catalog (steps then error by name). */
47
+ export function loadMockCatalog(screenName: string, cwd: string = process.cwd()): Record<string, MockEntry> {
48
+ const p = mockCatalogPath(screenName, cwd);
49
+ if (!fs.existsSync(p)) return {};
50
+ const raw = parseYaml(fs.readFileSync(p, 'utf-8'));
51
+ if (raw === null || raw === undefined) return {};
52
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
53
+ throw new Error(`${rel(p, cwd)}: expected a map of named mock entries`);
54
+ }
55
+ const catalog: Record<string, MockEntry> = {};
56
+ for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
57
+ catalog[name] = validateEntry(name, value, rel(p, cwd));
58
+ }
59
+ return catalog;
60
+ }
61
+
62
+ /** Resolve one entry by name — throws listing the valid names (fail-fast at compile). */
63
+ export function resolveMock(name: string, screenName: string, cwd: string = process.cwd()): MockEntry {
64
+ const catalog = loadMockCatalog(screenName, cwd);
65
+ const entry = catalog[name];
66
+ if (!entry) {
67
+ const names = Object.keys(catalog);
68
+ const hint = names.length
69
+ ? `valid names: ${names.join(', ')}`
70
+ : `no catalog found — create ${rel(mockCatalogPath(screenName, cwd), cwd)} (mock catalogs are SCREEN units in this version; flows are not yet supported)`;
71
+ throw new Error(`mock "${name}" is not defined for screen "${screenName}" — ${hint}`);
72
+ }
73
+ return entry;
74
+ }
75
+
76
+ function validateEntry(name: string, value: unknown, file: string): MockEntry {
77
+ const where = `${file}: mock "${name}"`;
78
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
79
+ throw new Error(`${where}: entry must be a map with keys ${VALID_KEYS.join('/')}`);
80
+ }
81
+ const obj = value as Record<string, unknown>;
82
+ const unknown = Object.keys(obj).filter((k) => !VALID_KEYS.includes(k));
83
+ if (unknown.length) {
84
+ throw new Error(`${where}: unknown key(s) ${unknown.join(', ')} — valid keys: ${VALID_KEYS.join(', ')}` +
85
+ (unknown.includes('abort') ? ' (no `abort`: route.abort() is a network error, not a timeout — use a large delay_ms)' : ''));
86
+ }
87
+ const method = String(obj.method ?? '').toUpperCase();
88
+ if (!obj.method || !METHODS.includes(method)) {
89
+ throw new Error(`${where}: "method" is required and must be one of ${METHODS.join('/')}`);
90
+ }
91
+ if (!obj.url || typeof obj.url !== 'string') {
92
+ throw new Error(`${where}: "url" is required (a page.route glob, or "re:<pattern>" for a regex)`);
93
+ }
94
+ const status = Number(obj.status);
95
+ if (obj.status === undefined || !Number.isInteger(status) || status < 100 || status > 599) {
96
+ throw new Error(`${where}: "status" is required and must be an HTTP status code (100-599)`);
97
+ }
98
+ if (obj.delay_ms !== undefined && (!Number.isFinite(Number(obj.delay_ms)) || Number(obj.delay_ms) < 0)) {
99
+ throw new Error(`${where}: "delay_ms" must be a non-negative number`);
100
+ }
101
+ if (obj.headers !== undefined && (typeof obj.headers !== 'object' || obj.headers === null || Array.isArray(obj.headers))) {
102
+ throw new Error(`${where}: "headers" must be a map of header name → value`);
103
+ }
104
+ return {
105
+ name,
106
+ method,
107
+ url: obj.url,
108
+ status,
109
+ headers: obj.headers as Record<string, string> | undefined,
110
+ body: obj.body,
111
+ delay_ms: obj.delay_ms === undefined ? undefined : Number(obj.delay_ms),
112
+ };
113
+ }
114
+
115
+ function rel(p: string, cwd: string): string {
116
+ return path.relative(cwd, p) || p;
117
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Mock step patterns — catalog-ref grammar only (no inline request definitions):
3
+ *
4
+ * Given [reservations-check-500] mock is active
5
+ * Then User see [reservations-check-500] mock called {{n}} times # also: called 1 time
6
+ *
7
+ * `[name]` is a CATALOG reference, not a selector — the pattern parses the bracket
8
+ * and resolves the entry from mocks.yaml at COMPILE time (unknown name = compile
9
+ * error listing valid names), then inlines the response config into the generated
10
+ * spec. A `{{var}}` body compiles to JSON.stringify(testData.raw('var')) directly —
11
+ * never through the marker pipeline.
12
+ *
13
+ * Priority 70-73: band 60 is taken by the db patterns — sharing it would make
14
+ * capability composition depend on registration-order tie-breaks. Matchers run on
15
+ * MASKED text ([name] → [~]) and anchor on the `mock is active` / `mock called`
16
+ * discriminators, which live outside the bracket.
17
+ */
18
+
19
+ import { StepPattern } from '@sun-asterisk/sungen';
20
+ import { MockEntry, resolveMock } from '../mock-catalog';
21
+
22
+ const reInstallMasked = /\[~\]\s+mock\s+is\s+active\b/i;
23
+ const reCallCountMasked = /\[~\]\s+mock\s+called\s+(?:\{\{~\}\}|\d+)\s+times?\b/i;
24
+
25
+ /** The capability is active for a feature when any step speaks mock grammar (no tag needed). */
26
+ export function isMockStep(text: string): boolean {
27
+ // Raw-text twins of the masked matchers (callers pass ORIGINAL step text here).
28
+ return /\[[^\]]+\]\s+mock\s+is\s+active\b/i.test(text) ||
29
+ /\[[^\]]+\]\s+mock\s+called\s+(?:\{\{[^}]+\}\}|\d+)\s+times?\b/i.test(text);
30
+ }
31
+
32
+ /** Bracket content from the ORIGINAL text (resolvers never see masked text). */
33
+ function mockName(text: string): string {
34
+ const m = text.match(/\[([^\]]+)\]\s+mock\b/i);
35
+ return m ? m[1] : '';
36
+ }
37
+
38
+ /**
39
+ * Render the entry as the JS config object literal `mock.install` receives.
40
+ * The body is serialized HERE (compile time): a `"{{var}}"` body becomes a
41
+ * runtime testData.raw() expression; anything else is baked in as JSON.
42
+ */
43
+ function entryExpr(entry: MockEntry): string {
44
+ const parts = [
45
+ `name: ${JSON.stringify(entry.name)}`,
46
+ `method: ${JSON.stringify(entry.method)}`,
47
+ `url: ${JSON.stringify(entry.url)}`,
48
+ `status: ${entry.status}`,
49
+ ];
50
+ if (entry.headers) parts.push(`headers: ${JSON.stringify(entry.headers)}`);
51
+ if (entry.delay_ms !== undefined) parts.push(`delay_ms: ${entry.delay_ms}`);
52
+ if (entry.body !== undefined) {
53
+ const ref = typeof entry.body === 'string' && /^\{\{\s*([^}]+?)\s*\}\}$/.exec(entry.body);
54
+ parts.push(ref
55
+ ? `body: JSON.stringify(testData.raw(${JSON.stringify(ref[1])}))`
56
+ : `body: ${JSON.stringify(JSON.stringify(entry.body))}`);
57
+ }
58
+ return `{ ${parts.join(', ')} }`;
59
+ }
60
+
61
+ export const mockPatterns: StepPattern[] = [
62
+ {
63
+ name: 'mock-install',
64
+ matcher: (step) => reInstallMasked.test(step.text),
65
+ generator: (step, context) => {
66
+ const name = mockName(step.text);
67
+ const entry = resolveMock(name, context.screenName || context.featureName || '', process.cwd());
68
+ return {
69
+ code: `await mock.install(page, ${entryExpr(entry)});`,
70
+ comment: `Mock ${name}: ${entry.method} ${entry.url} → ${entry.status}${entry.delay_ms ? ` after ${entry.delay_ms}ms` : ''}`,
71
+ };
72
+ },
73
+ priority: 70,
74
+ },
75
+ {
76
+ name: 'mock-call-count',
77
+ matcher: (step) => reCallCountMasked.test(step.text),
78
+ generator: (step, context) => {
79
+ const name = mockName(step.text);
80
+ // Compile-time validation: the name must exist in the catalog (a typo here
81
+ // otherwise becomes a runtime "never installed" that reads like a test bug).
82
+ resolveMock(name, context.screenName || context.featureName || '', process.cwd());
83
+ const m = step.text.match(/called\s+(\{\{[^}]+\}\}|\d+)\s+times?\b/i);
84
+ const raw = m ? m[1] : '0';
85
+ const v = raw.match(/^\{\{\s*([^}]+?)\s*\}\}$/);
86
+ const expected = v ? `Number(testData.get(${JSON.stringify(v[1])}))` : raw;
87
+ // callCount THROWS for a name never installed in this scenario — an exact 0
88
+ // on an uninstalled mock must be a loud error, not a passing assertion.
89
+ return {
90
+ code: `expect(mock.callCount(page, ${JSON.stringify(name)}), ${JSON.stringify(`mock ${name} call count`)}).toBe(${expected});`,
91
+ comment: `Assert mock ${name} called ${raw} time(s)`,
92
+ };
93
+ },
94
+ priority: 71,
95
+ },
96
+ ];