Skip to content

Commit 986c360

Browse files
committed
added pairwise to cli, api and mcp
1 parent b985a17 commit 986c360

15 files changed

Lines changed: 322 additions & 6 deletions

File tree

apps/api/src/fromschema.route.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,23 @@ test('/v1/generate/fromschema applies unit-test defaults for includeSetup', asyn
156156
expect(resetDefaults.status).toBe(200);
157157
}
158158
});
159+
160+
test('/v1/generate/fromschema supports pairwise query flag', async () => {
161+
const response = await fetch(url('/v1/generate/fromschema?rowCount=50&pairwise=true&outputFormat=json'), {
162+
method: 'POST',
163+
headers: { 'content-type': 'text/plain' },
164+
body: 'Browser\nChrome,Firefox,Safari\nTheme\nLight,Dark',
165+
});
166+
167+
expect(response.status).toBe(200);
168+
const body = await response.json();
169+
expect(body.headers).toEqual(['Browser', 'Theme']);
170+
expect(body.rows).toEqual([
171+
['Chrome', 'Light'],
172+
['Chrome', 'Dark'],
173+
['Firefox', 'Light'],
174+
['Firefox', 'Dark'],
175+
['Safari', 'Light'],
176+
['Safari', 'Dark'],
177+
]);
178+
});

apps/api/src/generate.route.test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,54 @@ test('/v1/generate parity: REST rendered matches core for all unit-test framewor
135135
expect(body.rendered).toBe(coreResult.rendered);
136136
}
137137
});
138+
139+
test('/v1/generate supports pairwise mode', async () => {
140+
const response = await fetch(url('/v1/generate'), {
141+
method: 'POST',
142+
headers: { 'content-type': 'application/json' },
143+
body: JSON.stringify({
144+
textSpec: 'Browser\nChrome,Firefox,Safari\nTheme\nLight,Dark',
145+
rowCount: 99,
146+
outputFormat: 'json',
147+
pairwise: true,
148+
}),
149+
});
150+
151+
expect(response.status).toBe(200);
152+
const body = await response.json();
153+
expect(body.headers).toEqual(['Browser', 'Theme']);
154+
expect(body.rows).toEqual([
155+
['Chrome', 'Light'],
156+
['Chrome', 'Dark'],
157+
['Firefox', 'Light'],
158+
['Firefox', 'Dark'],
159+
['Safari', 'Light'],
160+
['Safari', 'Dark'],
161+
]);
162+
});
163+
164+
test('/v1/generate supports pairwise for x-www-form-urlencoded payloads', async () => {
165+
const form = new URLSearchParams();
166+
form.set('textSpec', 'Browser\nChrome,Firefox,Safari\nTheme\nLight,Dark');
167+
form.set('rowCount', '99');
168+
form.set('outputFormat', 'json');
169+
form.set('pairwise', 'true');
170+
171+
const response = await fetch(url('/v1/generate'), {
172+
method: 'POST',
173+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
174+
body: form.toString(),
175+
});
176+
177+
expect(response.status).toBe(200);
178+
const body = await response.json();
179+
expect(body.headers).toEqual(['Browser', 'Theme']);
180+
expect(body.rows).toEqual([
181+
['Chrome', 'Light'],
182+
['Chrome', 'Dark'],
183+
['Firefox', 'Light'],
184+
['Firefox', 'Dark'],
185+
['Safari', 'Light'],
186+
['Safari', 'Dark'],
187+
]);
188+
});

apps/api/src/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ function resetDefaultOptionsForFormat(format) {
361361
}
362362

363363
function runGeneration(payload = {}) {
364-
const { textSpec, rowCount, outputFormat = 'csv', options, seed, unsafeFakerExpressions } = payload;
364+
const { textSpec, rowCount, outputFormat = 'csv', options, seed, pairwise, unsafeFakerExpressions } = payload;
365365
const concreteOutputFormat = String(outputFormat || 'csv').toLowerCase();
366366

367367
if (!SUPPORTED_FORMATS.includes(String(outputFormat).toLowerCase())) {
@@ -403,6 +403,7 @@ function runGeneration(payload = {}) {
403403
outputFormat: concreteOutputFormat,
404404
options: effectiveOptions,
405405
seed: parsedSeed.seed,
406+
pairwise: parseBooleanFlag(pairwise),
406407
unsafeFakerExpressions: unsafeFakerExpressions || false,
407408
});
408409
if (!result?.ok) {
@@ -494,6 +495,7 @@ function buildFromSchemaPayload(req) {
494495
rowCount,
495496
outputFormat: outputFormat || 'csv',
496497
seed,
498+
pairwise: req.query?.pairwise === 'true',
497499
responseFormat,
498500
unsafeFakerExpressions: unsafeFakerExpressions === 'true',
499501
};

apps/api/src/openapi.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ const openApiDocument = {
8686
},
8787
options: { type: 'object' },
8888
seed: { type: 'number' },
89+
pairwise: {
90+
type: 'boolean',
91+
default: false,
92+
description: 'Generate pairwise combinations for ENUM fields (requires at least 2 ENUM rules).',
93+
},
8994
unsafeFakerExpressions: {
9095
type: 'boolean',
9196
default: false,
@@ -145,6 +150,11 @@ const openApiDocument = {
145150
default: 'csv',
146151
},
147152
seed: { type: 'number' },
153+
pairwise: {
154+
type: 'boolean',
155+
default: false,
156+
description: 'Generate pairwise combinations for ENUM fields (requires at least 2 ENUM rules).',
157+
},
148158
unsafeFakerExpressions: {
149159
type: 'boolean',
150160
default: false,
@@ -278,6 +288,16 @@ const openApiDocument = {
278288
required: false,
279289
schema: { type: 'number' },
280290
},
291+
{
292+
in: 'query',
293+
name: 'pairwise',
294+
required: false,
295+
schema: {
296+
type: 'boolean',
297+
default: false,
298+
},
299+
description: 'Generate pairwise combinations for ENUM fields (requires at least 2 ENUM rules).',
300+
},
281301
{
282302
in: 'query',
283303
name: 'unsafeFakerExpressions',

apps/cli/src/cli-options.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export function parseCliOptions(argvInput = process.argv) {
4747
default: false,
4848
describe: 'Allow expression-style faker arguments (unsafe for untrusted input)',
4949
})
50+
.option('pairwise', {
51+
type: 'boolean',
52+
default: false,
53+
describe: 'Generate pairwise combinations for ENUM fields (requires at least 2 ENUM rules)',
54+
})
5055
.help('h')
5156
.alias('h', 'help')
5257
.parseSync();
@@ -66,5 +71,6 @@ export function parseCliOptions(argvInput = process.argv) {
6671
showProgress,
6772
shouldStream,
6873
unsafeFakerExpressions: parsed['unsafe-faker-expressions'] === true,
74+
pairwise: parsed.pairwise === true,
6975
};
7076
}

apps/cli/src/run-cli.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,14 @@ export async function runCliCommand({ options, platform }) {
2828
if (options.testMode) {
2929
progress('> Operating in Test Mode - generating 1 entry');
3030
}
31+
const useStreamMode = options.shouldStream && !options.pairwise;
32+
if (options.pairwise && options.shouldStream) {
33+
if (options.testMode) {
34+
progress('WARNING: Streaming is ignored when pairwise generation is enabled; using buffered mode.');
35+
}
36+
}
3137

32-
if (options.shouldStream && (options.format === 'csv' || options.format === 'jsonl')) {
38+
if (useStreamMode && (options.format === 'csv' || options.format === 'jsonl')) {
3339
const streamedLines = [];
3440
const writer = options.outputFile ? platform.createLineWriter(options.outputFile) : null;
3541
let writerClosed = false;
@@ -38,6 +44,7 @@ export async function runCliCommand({ options, platform }) {
3844
textSpec,
3945
rowCount: options.rowCount,
4046
outputFormat: options.format,
47+
pairwise: options.pairwise,
4148
unsafeFakerExpressions: options.unsafeFakerExpressions,
4249
onChunk: async (chunk) => {
4350
if (writer) {
@@ -86,6 +93,7 @@ export async function runCliCommand({ options, platform }) {
8693
textSpec,
8794
rowCount: options.rowCount,
8895
outputFormat: options.format,
96+
pairwise: options.pairwise,
8997
unsafeFakerExpressions: options.unsafeFakerExpressions,
9098
});
9199

apps/cli/src/tests/cli-options.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,8 @@ test('stream auto-enabled for large file outputs', () => {
3535
]);
3636
expect(opts.shouldStream).toBe(true);
3737
});
38+
39+
test('pairwise flag is parsed', () => {
40+
const opts = parseCliOptions(['node', 'cli', 'generate', '-i', 'spec.txt', '-n', '5', '--pairwise']);
41+
expect(opts.pairwise).toBe(true);
42+
});

apps/cli/src/tests/run-cli.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ test('returns error when input file cannot be read', async () => {
5050
showProgress: false,
5151
shouldStream: false,
5252
unsafeFakerExpressions: false,
53+
pairwise: false,
5354
},
5455
});
5556
expect(code).toBe(1);
@@ -69,6 +70,7 @@ test('writes output file in buffered mode', async () => {
6970
showProgress: false,
7071
shouldStream: false,
7172
unsafeFakerExpressions: false,
73+
pairwise: false,
7274
},
7375
});
7476
expect(code).toBe(0);
@@ -96,9 +98,81 @@ test('returns error when stream writer fails', async () => {
9698
showProgress: false,
9799
shouldStream: true,
98100
unsafeFakerExpressions: false,
101+
pairwise: false,
99102
},
100103
});
101104

102105
expect(code).toBe(1);
103106
expect(platform.err.join('')).toContain('Streaming generation failed');
104107
});
108+
109+
test('ignores stream mode when pairwise is requested', async () => {
110+
const platform = makePlatform({ textSpec: 'Browser\nChrome,Firefox,Safari\nTheme\nLight,Dark' });
111+
const code = await runCliCommand({
112+
platform,
113+
options: {
114+
inputFile: 'spec.txt',
115+
outputFile: 'out.csv',
116+
format: 'csv',
117+
rowCount: 2,
118+
testMode: false,
119+
showProgress: false,
120+
shouldStream: true,
121+
unsafeFakerExpressions: false,
122+
pairwise: true,
123+
},
124+
});
125+
expect(code).toBe(0);
126+
expect(platform.err.join('')).toBe('');
127+
expect(platform.writes.length).toBe(1);
128+
expect(platform.writes[0].path).toBe('out.csv');
129+
});
130+
131+
test('reports warning in test mode when stream mode is ignored for pairwise', async () => {
132+
const platform = makePlatform({ textSpec: 'Browser\nChrome,Firefox,Safari\nTheme\nLight,Dark' });
133+
const code = await runCliCommand({
134+
platform,
135+
options: {
136+
inputFile: 'spec.txt',
137+
outputFile: 'out.csv',
138+
format: 'csv',
139+
rowCount: 2,
140+
testMode: true,
141+
showProgress: true,
142+
shouldStream: true,
143+
unsafeFakerExpressions: false,
144+
pairwise: true,
145+
},
146+
});
147+
expect(code).toBe(0);
148+
expect(platform.out.join('')).toContain('WARNING: Streaming is ignored when pairwise generation is enabled');
149+
});
150+
151+
test('generates deterministic pairwise output in buffered mode', async () => {
152+
const platform = makePlatform({ textSpec: 'Browser\nChrome,Firefox,Safari\nTheme\nLight,Dark' });
153+
const code = await runCliCommand({
154+
platform,
155+
options: {
156+
inputFile: 'spec.txt',
157+
outputFile: null,
158+
format: 'json',
159+
rowCount: 100,
160+
testMode: false,
161+
showProgress: false,
162+
shouldStream: false,
163+
unsafeFakerExpressions: false,
164+
pairwise: true,
165+
},
166+
});
167+
168+
expect(code).toBe(0);
169+
const rendered = platform.out.join('').trim();
170+
expect(JSON.parse(rendered)).toEqual([
171+
{ Browser: 'Chrome', Theme: 'Light' },
172+
{ Browser: 'Chrome', Theme: 'Dark' },
173+
{ Browser: 'Firefox', Theme: 'Light' },
174+
{ Browser: 'Firefox', Theme: 'Dark' },
175+
{ Browser: 'Safari', Theme: 'Light' },
176+
{ Browser: 'Safari', Theme: 'Dark' },
177+
]);
178+
});

apps/mcp/src/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,10 @@ function handleRequest(request) {
384384
outputFormat: { type: 'string', enum: SUPPORTED_FORMATS },
385385
options: GENERATE_OPTIONS_SCHEMA,
386386
seed: { type: 'number' },
387+
pairwise: {
388+
type: 'boolean',
389+
description: 'Generate pairwise combinations for ENUM fields (requires at least 2 ENUM rules).',
390+
},
387391
},
388392
required: ['textSpec', 'rowCount', 'outputFormat'],
389393
},

apps/mcp/src/mcp.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@ test('MCP server handles generate_data_from_spec tool call', () => {
4949
expect(response?.result?.structuredContent?.ok).toBe(true);
5050
});
5151

52+
test('MCP server supports pairwise generation', () => {
53+
const response = requestServer({
54+
jsonrpc: '2.0',
55+
id: 200,
56+
method: 'tools/call',
57+
params: {
58+
name: 'generate_data_from_spec',
59+
arguments: {
60+
textSpec: 'Browser\nChrome,Firefox,Safari\nTheme\nLight,Dark',
61+
rowCount: 100,
62+
outputFormat: 'json',
63+
pairwise: true,
64+
},
65+
},
66+
});
67+
const payload = JSON.parse(response?.result?.content?.[0]?.text || '{}');
68+
expect(payload.ok).toBe(true);
69+
expect(payload.headers).toEqual(['Browser', 'Theme']);
70+
expect(payload.rows).toEqual([
71+
['Chrome', 'Light'],
72+
['Chrome', 'Dark'],
73+
['Firefox', 'Light'],
74+
['Firefox', 'Dark'],
75+
['Safari', 'Light'],
76+
['Safari', 'Dark'],
77+
]);
78+
});
79+
5280
test('MCP server handles test framework output format', () => {
5381
const response = requestServer({
5482
jsonrpc: '2.0',

0 commit comments

Comments
 (0)