Skip to content

Commit 432796e

Browse files
Stephen Millerclaude
authored andcommitted
test(sync): add scenario tests for mgw:sync drift detection and auto-sync
Covers four scenarios from issue #255: - No-drift baseline: GH updatedAt <= local mtime → no sync fires - Stale auto-sync: GH updatedAt > local mtime → state refreshed, console.warn logged - Comment delta drift: current count > stored count → flagged in report - PR merged but stage=pr-created → pipeline_stage transitions to done 28 tests across 5 describe blocks (plus integration sync report verification). Uses mock-github.cjs for gh CLI interception and fs.utimesSync for mtime control. Also includes vitest infrastructure (vitest.config.js, test/setup.js, test/loadFixture.js, lib/mock-github.cjs, test/fixtures/github/) carried forward from PR #265 dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e352921 commit 432796e

25 files changed

Lines changed: 2539 additions & 15 deletions

lib/mock-github.cjs

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
'use strict';
2+
3+
/**
4+
* lib/mock-github.cjs — GitHub API interceptor for tests
5+
*
6+
* Intercepts `child_process.execSync` calls that invoke the `gh` CLI, returning
7+
* pre-baked fixture responses instead of making real network calls.
8+
*
9+
* Usage:
10+
*
11+
* const mockGitHub = require('./lib/mock-github.cjs');
12+
*
13+
* // Activate before test (optionally pass a scenario name)
14+
* mockGitHub.activate(); // base fixtures only
15+
* mockGitHub.activate('pr-error'); // load test/fixtures/github/pr-error/ overrides
16+
*
17+
* // ... run code that calls gh CLI ...
18+
*
19+
* const calls = mockGitHub.getCallLog(); // inspect what was called
20+
* mockGitHub.deactivate(); // restore real execSync
21+
*
22+
* Scenario support:
23+
* Scenarios live in test/fixtures/github/<scenario>/ and override base fixtures.
24+
* Any fixture key present in the scenario directory takes precedence over the
25+
* corresponding base fixture. This allows targeted per-test overrides.
26+
*
27+
* Inline overrides (highest precedence):
28+
* mockGitHub.setResponse('gh issue view', '{"number":999}');
29+
*
30+
* Call log format:
31+
* Each entry: { cmd, fixture, returnValue, timestamp }
32+
*
33+
* Safety:
34+
* - Re-activating without deactivating first is safe (auto-deactivates).
35+
* - Module never makes real gh CLI calls.
36+
* - Fixture load errors throw descriptive Error messages.
37+
*/
38+
39+
const path = require('path');
40+
const fs = require('fs');
41+
const childProcess = require('child_process');
42+
43+
// ---------------------------------------------------------------------------
44+
// Path resolution
45+
// ---------------------------------------------------------------------------
46+
47+
/**
48+
* Resolve the fixtures base directory relative to this file.
49+
* Works whether installed as a package or used in-repo.
50+
*/
51+
function resolveFixturesDir() {
52+
// Walk up from lib/ to find test/fixtures/github/
53+
const libDir = __dirname;
54+
const repoRoot = path.resolve(libDir, '..');
55+
return path.join(repoRoot, 'test', 'fixtures', 'github');
56+
}
57+
58+
// ---------------------------------------------------------------------------
59+
// Fixture loading
60+
// ---------------------------------------------------------------------------
61+
62+
/**
63+
* Load a fixture file and return its contents as a string.
64+
* Returns the raw file content — callers receive exactly what execSync would.
65+
*
66+
* For fixtures whose JSON root is a string (e.g. `"https://..."`) we strip
67+
* the outer quotes since execSync output never includes JSON string quoting.
68+
* For fixtures whose JSON root is an object or array, we return the raw JSON.
69+
*
70+
* @param {string} fixtureKey - e.g. "issue-view", "pr-create"
71+
* @param {string} baseDir - resolved fixtures base directory
72+
* @param {string|null} scenarioDir - resolved scenario override directory (or null)
73+
* @returns {string} fixture content as execSync would return it
74+
* @throws {Error} if fixture file not found in either location
75+
*/
76+
function loadFixture(fixtureKey, baseDir, scenarioDir) {
77+
const filename = `${fixtureKey}.json`;
78+
79+
// Scenario directory takes precedence
80+
if (scenarioDir) {
81+
const scenarioPath = path.join(scenarioDir, filename);
82+
if (fs.existsSync(scenarioPath)) {
83+
return parseFixtureFile(scenarioPath, fixtureKey);
84+
}
85+
}
86+
87+
// Base fixture
88+
const basePath = path.join(baseDir, filename);
89+
if (fs.existsSync(basePath)) {
90+
return parseFixtureFile(basePath, fixtureKey);
91+
}
92+
93+
throw new Error(
94+
`mock-github: fixture not found: "${fixtureKey}" (looked for ${filename} in ${baseDir}${scenarioDir ? ` and ${scenarioDir}` : ''})`
95+
);
96+
}
97+
98+
/**
99+
* Read a fixture file and convert its content to execSync-compatible output.
100+
*
101+
* JSON strings (root is a quoted string) are unwrapped: `"foo"` → `foo`
102+
* JSON objects/arrays are returned as compact JSON strings.
103+
* Empty strings (`""`) are returned as `""`.
104+
*
105+
* @param {string} filePath - absolute path to fixture .json file
106+
* @param {string} fixtureKey - used in error messages
107+
* @returns {string}
108+
*/
109+
function parseFixtureFile(filePath, fixtureKey) {
110+
let raw;
111+
try {
112+
raw = fs.readFileSync(filePath, 'utf-8').trim();
113+
} catch (err) {
114+
throw new Error(`mock-github: failed to read fixture "${fixtureKey}" at ${filePath}: ${err.message}`);
115+
}
116+
117+
let parsed;
118+
try {
119+
parsed = JSON.parse(raw);
120+
} catch (err) {
121+
throw new Error(`mock-github: fixture "${fixtureKey}" is not valid JSON (${filePath}): ${err.message}`);
122+
}
123+
124+
// Unwrap JSON string values (execSync output is never JSON-encoded strings)
125+
if (typeof parsed === 'string') {
126+
return parsed;
127+
}
128+
129+
// Objects and arrays: return compact JSON (callers parse with JSON.parse)
130+
return JSON.stringify(parsed);
131+
}
132+
133+
// ---------------------------------------------------------------------------
134+
// Command routing
135+
// ---------------------------------------------------------------------------
136+
137+
/**
138+
* Route table: ordered list of [pattern, fixtureKey] pairs.
139+
* First match wins. Patterns are matched against the full command string.
140+
*
141+
* Built-in responses (not loaded from fixtures) are also handled in routeCommand().
142+
*/
143+
const ROUTE_TABLE = [
144+
// Issue operations
145+
[/\bgh issue view\b/, 'issue-view'],
146+
[/\bgh issue list\b/, 'issue-list'],
147+
[/\bgh issue comment\b/, 'issue-comment'],
148+
[/\bgh issue edit\b/, 'issue-edit'],
149+
150+
// Milestone operations (order matters: PATCH before GET)
151+
[/\bgh api\b.*\/milestones\/\d+.*--method PATCH/, 'milestone-close'],
152+
[/\bgh api\b.*--method POST.*\/milestones/, 'milestone-create'],
153+
[/\bgh api\b.*\/milestones\b.*--method POST/, 'milestone-create'],
154+
[/\bgh api repos\/.*\/milestones\/\d+/, 'milestone-view'],
155+
156+
// Label operations
157+
[/\bgh label create\b/, 'label-create'],
158+
[/\bgh label list\b/, 'label-list'],
159+
160+
// PR operations
161+
[/\bgh pr create\b/, 'pr-create'],
162+
[/\bgh pr view\b/, 'pr-view'],
163+
164+
// Rate limit
165+
[/\bgh api rate_limit\b/, 'rate-limit'],
166+
167+
// Board / GraphQL operations (order matters: specific mutations before generic graphql)
168+
[/\bgh api graphql\b.*updateProjectV2ItemFieldValue/, 'graphql-board-mutation'],
169+
[/\bgh api graphql\b.*discussionCategories/, 'repo-meta'],
170+
[/\bgh api graphql\b.*createDiscussion/, 'discussion-create'],
171+
[/\bgh project item-add\b/, 'board-item'],
172+
];
173+
174+
/**
175+
* Built-in responses — returned directly without loading a fixture file.
176+
* These cover repo identity and user queries that are near-universal.
177+
*/
178+
const BUILTINS = [
179+
[/\bgh repo view\b/, 'snipcodeit/mgw'],
180+
[/\bgh api user\b/, '{"login":"snipcodeit"}'],
181+
[/\bgh api\b.*\/user\b/, '{"login":"snipcodeit"}'],
182+
];
183+
184+
/**
185+
* Find the matching fixture key or builtin value for a command string.
186+
*
187+
* @param {string} cmd - the execSync command string
188+
* @param {Map<string, string>} inlineOverrides - per-command inline overrides
189+
* @returns {{ type: 'fixture'|'builtin'|'empty', key?: string, value?: string }}
190+
*/
191+
function routeCommand(cmd, inlineOverrides) {
192+
// 1. Inline overrides (highest precedence) — match by prefix/substring
193+
for (const [pattern, value] of inlineOverrides) {
194+
if (cmd.includes(pattern)) {
195+
return { type: 'builtin', key: pattern, value };
196+
}
197+
}
198+
199+
// 2. Builtin responses
200+
for (const [pattern, value] of BUILTINS) {
201+
if (pattern.test(cmd)) {
202+
return { type: 'builtin', key: String(pattern), value };
203+
}
204+
}
205+
206+
// 3. Route table → fixture key
207+
for (const [pattern, fixtureKey] of ROUTE_TABLE) {
208+
if (pattern.test(cmd)) {
209+
return { type: 'fixture', key: fixtureKey };
210+
}
211+
}
212+
213+
// 4. Default: empty string (unknown command)
214+
return { type: 'empty', key: null };
215+
}
216+
217+
// ---------------------------------------------------------------------------
218+
// State
219+
// ---------------------------------------------------------------------------
220+
221+
/** The original child_process.execSync before any mock was installed */
222+
let _originalExecSync = null;
223+
224+
/** Whether the mock is currently active */
225+
let _active = false;
226+
227+
/** Ordered log of intercepted calls */
228+
let _callLog = [];
229+
230+
/** Resolved path to base fixtures directory */
231+
let _baseDir = null;
232+
233+
/** Resolved path to scenario override directory (or null) */
234+
let _scenarioDir = null;
235+
236+
/** Per-command inline overrides: Map<string, string> (pattern string → return value) */
237+
let _inlineOverrides = new Map();
238+
239+
// ---------------------------------------------------------------------------
240+
// Core API
241+
// ---------------------------------------------------------------------------
242+
243+
/**
244+
* Activate the mock. Replaces child_process.execSync with an interceptor.
245+
*
246+
* Safe to call when already active — deactivates first, then re-activates.
247+
*
248+
* @param {string} [scenario] - Optional scenario name. If provided, fixtures from
249+
* `test/fixtures/github/<scenario>/` override the base fixtures.
250+
* @throws {Error} if the fixtures base directory does not exist
251+
*/
252+
function activate(scenario) {
253+
if (_active) {
254+
deactivate();
255+
}
256+
257+
_baseDir = resolveFixturesDir();
258+
259+
if (!fs.existsSync(_baseDir)) {
260+
throw new Error(
261+
`mock-github: fixtures directory not found: ${_baseDir}\n` +
262+
'Create test/fixtures/github/ with fixture JSON files before activating the mock.'
263+
);
264+
}
265+
266+
if (scenario) {
267+
_scenarioDir = path.join(_baseDir, scenario);
268+
if (!fs.existsSync(_scenarioDir)) {
269+
throw new Error(
270+
`mock-github: scenario directory not found: ${_scenarioDir}`
271+
);
272+
}
273+
} else {
274+
_scenarioDir = null;
275+
}
276+
277+
_callLog = [];
278+
_inlineOverrides = new Map();
279+
280+
// Store original and install interceptor
281+
_originalExecSync = childProcess.execSync;
282+
283+
childProcess.execSync = function mockExecSync(cmd, _opts) {
284+
const route = routeCommand(cmd, _inlineOverrides);
285+
286+
let returnValue;
287+
let fixtureKey;
288+
289+
if (route.type === 'builtin') {
290+
returnValue = route.value;
291+
fixtureKey = route.key;
292+
} else if (route.type === 'fixture') {
293+
returnValue = loadFixture(route.key, _baseDir, _scenarioDir);
294+
fixtureKey = route.key;
295+
} else {
296+
returnValue = '';
297+
fixtureKey = null;
298+
}
299+
300+
_callLog.push({
301+
cmd,
302+
fixture: fixtureKey,
303+
returnValue,
304+
timestamp: new Date().toISOString(),
305+
});
306+
307+
return returnValue;
308+
};
309+
310+
_active = true;
311+
}
312+
313+
/**
314+
* Deactivate the mock. Restores the original child_process.execSync.
315+
* Safe to call when not active (no-op).
316+
*/
317+
function deactivate() {
318+
if (!_active) return;
319+
320+
childProcess.execSync = _originalExecSync;
321+
_originalExecSync = null;
322+
_active = false;
323+
_baseDir = null;
324+
_scenarioDir = null;
325+
_inlineOverrides = new Map();
326+
// Note: call log is preserved after deactivation — callers inspect it after the test
327+
}
328+
329+
/**
330+
* Return the ordered array of intercepted call entries since the last activate().
331+
* Each entry: { cmd, fixture, returnValue, timestamp }
332+
*
333+
* @returns {Array<{cmd: string, fixture: string|null, returnValue: string, timestamp: string}>}
334+
*/
335+
function getCallLog() {
336+
return _callLog.slice(); // defensive copy
337+
}
338+
339+
/**
340+
* Clear the call log without deactivating the mock.
341+
* Useful for resetting between sub-scenarios in a single test.
342+
*/
343+
function clearCallLog() {
344+
_callLog = [];
345+
}
346+
347+
/**
348+
* Set an inline response override for commands matching the given pattern string.
349+
* The pattern is matched with String.prototype.includes() against the full command.
350+
* Inline overrides take precedence over all other routing (builtins and fixture table).
351+
*
352+
* Must be called after activate().
353+
*
354+
* @param {string} cmdPattern - Substring to match in the command string
355+
* @param {string} returnValue - Value to return when the pattern matches
356+
* @throws {Error} if called before activate()
357+
*/
358+
function setResponse(cmdPattern, returnValue) {
359+
if (!_active) {
360+
throw new Error('mock-github: setResponse() called before activate(). Call activate() first.');
361+
}
362+
_inlineOverrides.set(cmdPattern, returnValue);
363+
}
364+
365+
/**
366+
* Whether the mock is currently active.
367+
* Useful for guard assertions in test setup/teardown.
368+
*
369+
* @returns {boolean}
370+
*/
371+
function isActive() {
372+
return _active;
373+
}
374+
375+
// ---------------------------------------------------------------------------
376+
// Exports
377+
// ---------------------------------------------------------------------------
378+
379+
module.exports = {
380+
activate,
381+
deactivate,
382+
getCallLog,
383+
clearCallLog,
384+
setResponse,
385+
isActive,
386+
};

0 commit comments

Comments
 (0)