Skip to content

Commit 15b41a7

Browse files
authored
Merge pull request #74 from eviltester/52-pairwise-support-in-cli-api-and-mcp
added pairwise to cli, api and mcp closes #52
2 parents b985a17 + da31d1b commit 15b41a7

18 files changed

Lines changed: 420 additions & 11 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,11 +235,16 @@ Supported options:
235235

236236
- `-i, --inputfile` path to the input text spec (required)
237237
- `-n, --numberOfLines` number of rows to generate (default `1`)
238+
- `--pairwise` generate pairwise combinations for enum fields (requires at least 2 enum rules)
238239
- `-f, --format` output format (default `csv`)
239240
- `-o, --outputfile` write output to file instead of stdout
240241
- `-t, --testMode` enable diagnostics mode and generate one row
241242
- `--unsafe-faker-expressions` allow expression-style faker args (disabled by default)
242243

244+
Pairwise note:
245+
246+
- when `--pairwise` is enabled, `--numberOfLines` is accepted but ignored (the pairwise engine determines row count)
247+
243248
## REST API Quick Start
244249

245250
Start the API:
@@ -295,7 +300,8 @@ Raw multiline schema/spec endpoint:
295300

296301
- `Content-Type` must be `text/plain`
297302
- request body is the full multiline spec
298-
- query params: `rowCount` (required), `outputFormat` (optional), `seed` (optional), `responseFormat` (optional: `rows|rendered|all|raw`)
303+
- query params: `rowCount` (required), `outputFormat` (optional), `seed` (optional), `pairwise` (optional), `responseFormat` (optional: `rows|rendered|all|raw`)
304+
- pairwise note: when `pairwise=true`, `rowCount` is accepted for compatibility but ignored (pairwise output size is computed automatically)
299305

300306
Set format default options:
301307

@@ -411,6 +417,7 @@ Inputs:
411417

412418
- `textSpec` (required string)
413419
- `rowCount` (required integer, >= 0)
420+
- `pairwise` (optional boolean, default `false`; when `true`, `rowCount` is ignored)
414421
- `outputFormat` (required string e.g. `csv`, `json`, `jsonl`, `xml`, `sql`)
415422
- `options` (optional object)
416423
- `seed` (optional number)

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: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ const openApiDocument = {
4141
required: ['textSpec', 'rowCount'],
4242
properties: {
4343
textSpec: { type: 'string' },
44-
rowCount: { type: 'integer', minimum: 0 },
44+
rowCount: {
45+
type: 'integer',
46+
minimum: 0,
47+
description: 'Requested row count. Ignored when pairwise is true.',
48+
},
4549
outputFormat: {
4650
type: 'string',
4751
enum: [
@@ -86,6 +90,11 @@ const openApiDocument = {
8690
},
8791
options: { type: 'object' },
8892
seed: { type: 'number' },
93+
pairwise: {
94+
type: 'boolean',
95+
default: false,
96+
description: 'Generate pairwise combinations for ENUM fields (requires at least 2 ENUM rules).',
97+
},
8998
unsafeFakerExpressions: {
9099
type: 'boolean',
91100
default: false,
@@ -101,7 +110,11 @@ const openApiDocument = {
101110
required: ['textSpec', 'rowCount'],
102111
properties: {
103112
textSpec: { type: 'string' },
104-
rowCount: { type: 'integer', minimum: 0 },
113+
rowCount: {
114+
type: 'integer',
115+
minimum: 0,
116+
description: 'Requested row count. Ignored when pairwise is true.',
117+
},
105118
outputFormat: {
106119
type: 'string',
107120
enum: [
@@ -145,6 +158,11 @@ const openApiDocument = {
145158
default: 'csv',
146159
},
147160
seed: { type: 'number' },
161+
pairwise: {
162+
type: 'boolean',
163+
default: false,
164+
description: 'Generate pairwise combinations for ENUM fields (requires at least 2 ENUM rules).',
165+
},
148166
unsafeFakerExpressions: {
149167
type: 'boolean',
150168
default: false,
@@ -223,7 +241,7 @@ const openApiDocument = {
223241
name: 'rowCount',
224242
required: true,
225243
schema: { type: 'integer', minimum: 0 },
226-
description: 'Number of rows to generate',
244+
description: 'Number of rows to generate. Ignored when pairwise=true.',
227245
},
228246
{
229247
in: 'query',
@@ -278,6 +296,17 @@ const openApiDocument = {
278296
required: false,
279297
schema: { type: 'number' },
280298
},
299+
{
300+
in: 'query',
301+
name: 'pairwise',
302+
required: false,
303+
schema: {
304+
type: 'boolean',
305+
default: false,
306+
},
307+
description:
308+
'Generate pairwise combinations for ENUM fields (requires at least 2 ENUM rules). When true, rowCount is ignored.',
309+
},
281310
{
282311
in: 'query',
283312
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: 14 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

@@ -95,6 +103,11 @@ export async function runCliCommand({ options, platform }) {
95103
}
96104

97105
progress(result.diagnostics.report);
106+
if (Array.isArray(result.diagnostics.warnings)) {
107+
for (const warning of result.diagnostics.warnings) {
108+
progress(`WARNING: ${warning}`);
109+
}
110+
}
98111
if (options.testMode && result.rows.length > 0) {
99112
progress('e.g.');
100113
progress(JSON.stringify(result.rows[0]));

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: 75 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,82 @@ 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+
expect(platform.out.join('')).toContain('WARNING: rowCount is ignored when pairwise generation is enabled.');
150+
});
151+
152+
test('generates deterministic pairwise output in buffered mode', async () => {
153+
const platform = makePlatform({ textSpec: 'Browser\nChrome,Firefox,Safari\nTheme\nLight,Dark' });
154+
const code = await runCliCommand({
155+
platform,
156+
options: {
157+
inputFile: 'spec.txt',
158+
outputFile: null,
159+
format: 'json',
160+
rowCount: 100,
161+
testMode: false,
162+
showProgress: false,
163+
shouldStream: false,
164+
unsafeFakerExpressions: false,
165+
pairwise: true,
166+
},
167+
});
168+
169+
expect(code).toBe(0);
170+
const rendered = platform.out.join('').trim();
171+
expect(JSON.parse(rendered)).toEqual([
172+
{ Browser: 'Chrome', Theme: 'Light' },
173+
{ Browser: 'Chrome', Theme: 'Dark' },
174+
{ Browser: 'Firefox', Theme: 'Light' },
175+
{ Browser: 'Firefox', Theme: 'Dark' },
176+
{ Browser: 'Safari', Theme: 'Light' },
177+
{ Browser: 'Safari', Theme: 'Dark' },
178+
]);
179+
});

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
},

0 commit comments

Comments
 (0)