Skip to content

Commit 40a6a27

Browse files
authored
Add shared schema acceptance tests (#224)
* Add shared schema acceptance tests * Derive schema acceptance headers from output * Clarify CLI schema acceptance parse failures * Move API acceptance describe config first * Handle MCP schema acceptance RPC errors
1 parent 05e030e commit 40a6a27

12 files changed

Lines changed: 620 additions & 0 deletions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { test, expect } from '@playwright/test';
2+
import { createRequire } from 'node:module';
3+
import { setupApiServer, teardownApiServer, apiUrl } from '../api-test-setup.js';
4+
5+
const require = createRequire(import.meta.url);
6+
const {
7+
SCHEMA_ACCEPTANCE_SCENARIOS,
8+
} = require('../../../../../tests/integration/support/schema-acceptance-fixtures.cjs');
9+
const { normalizeApiBody } = require('../../../../../tests/integration/support/schema-acceptance-assertions.cjs');
10+
11+
test.describe('POST /v1/generate/fromschema schema acceptance', () => {
12+
test.describe.configure({ mode: 'serial' });
13+
14+
test.beforeAll(async () => {
15+
await setupApiServer();
16+
});
17+
18+
test.afterAll(async () => {
19+
await teardownApiServer();
20+
});
21+
22+
for (const scenario of SCHEMA_ACCEPTANCE_SCENARIOS) {
23+
test(`${scenario.id} matches shared acceptance criteria`, async ({ request }) => {
24+
const response = await request.post(
25+
apiUrl(`/v1/generate/fromschema?rowCount=${scenario.rowCount}&outputFormat=${scenario.outputFormat}`),
26+
{
27+
data: scenario.schemaText,
28+
headers: { 'content-type': 'text/plain' },
29+
}
30+
);
31+
32+
expect([200, 400]).toContain(response.status());
33+
const body = await response.json();
34+
const normalized = normalizeApiBody(body);
35+
scenario.assertAcceptance(expect, normalized);
36+
});
37+
}
38+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const { test } = require('@playwright/test');
2+
const { openApp, expectNoPageErrors, expect } = require('../../abstractions/helpers/scenario-helpers');
3+
const {
4+
SCHEMA_ACCEPTANCE_SCENARIOS,
5+
} = require('../../../../../../../../tests/integration/support/schema-acceptance-fixtures.cjs');
6+
const {
7+
normalizeUiGridResult,
8+
normalizeUiErrorText,
9+
} = require('../../../../../../../../tests/integration/support/schema-acceptance-assertions.cjs');
10+
11+
async function readGridRows(appPage, headers) {
12+
const rowCount = await appPage.gridEditor.renderer.countRows();
13+
const columns = await Promise.all(headers.map((header) => appPage.gridEditor.renderer.getColumnTextsByName(header)));
14+
const rows = [];
15+
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
16+
rows.push(columns.map((column) => column[rowIndex]));
17+
}
18+
return rows;
19+
}
20+
21+
test.describe('App test-data schema acceptance', () => {
22+
for (const scenario of SCHEMA_ACCEPTANCE_SCENARIOS) {
23+
test(`${scenario.id} matches shared acceptance criteria`, async ({ page }) => {
24+
const { appPage, pageErrors } = await openApp(page);
25+
26+
await appPage.testDataPanel.expand();
27+
await appPage.testDataPanel.expectExpanded();
28+
await appPage.testDataPanel.setSchemaText(scenario.schemaText);
29+
30+
if (scenario.kind === 'good') {
31+
await appPage.testDataPanel.setGenerateCount(scenario.rowCount);
32+
await appPage.testDataPanel.clickGenerate();
33+
34+
await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(scenario.rowCount);
35+
await expect.poll(async () => appPage.gridEditor.header.getColumnNames()).toEqual(scenario.expectedHeaders);
36+
37+
const rows = await readGridRows(appPage, scenario.expectedHeaders);
38+
const normalized = normalizeUiGridResult({
39+
headers: scenario.expectedHeaders,
40+
rows,
41+
});
42+
scenario.assertAcceptance(expect, normalized);
43+
} else {
44+
await appPage.testDataPanel.clickGenerate();
45+
await expect.poll(async () => await appPage.testDataPanel.getSchemaErrorText()).not.toBe('');
46+
const normalized = normalizeUiErrorText(await appPage.testDataPanel.getSchemaErrorText());
47+
scenario.assertAcceptance(expect, normalized);
48+
}
49+
50+
expectNoPageErrors(pageErrors);
51+
});
52+
}
53+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const fs = require('node:fs');
2+
const { test } = require('@playwright/test');
3+
const { openGenerator, expectNoPageErrors, expect } = require('../abstractions/helpers/scenario-helpers');
4+
const {
5+
SCHEMA_ACCEPTANCE_SCENARIOS,
6+
} = require('../../../../../../../tests/integration/support/schema-acceptance-fixtures.cjs');
7+
const {
8+
normalizeUiDownloadedJson,
9+
normalizeUiErrorText,
10+
} = require('../../../../../../../tests/integration/support/schema-acceptance-assertions.cjs');
11+
12+
async function downloadGeneratedDataText(generatorPage) {
13+
const download = await generatorPage.downloadGeneratedData();
14+
const filePath = await download.path();
15+
return fs.readFileSync(filePath, 'utf8');
16+
}
17+
18+
test.describe('Generator schema acceptance', () => {
19+
for (const scenario of SCHEMA_ACCEPTANCE_SCENARIOS) {
20+
test(`${scenario.id} matches shared acceptance criteria`, async ({ page }) => {
21+
const { generatorPage, pageErrors } = await openGenerator(page);
22+
23+
await generatorPage.schema.setSchemaText(scenario.schemaText);
24+
25+
if (scenario.kind === 'good') {
26+
await generatorPage.generateOptions.setRowsCount(scenario.rowCount);
27+
await generatorPage.generateOptions.setOutputFormat(scenario.outputFormat);
28+
29+
const outputText = await downloadGeneratedDataText(generatorPage);
30+
const normalized = normalizeUiDownloadedJson(outputText, scenario.expectedHeaders);
31+
scenario.assertAcceptance(expect, normalized);
32+
} else {
33+
await generatorPage.preview.clickPreview();
34+
await expect
35+
.poll(async () => (await generatorPage.schema.errorStatus.textContent())?.trim() || '')
36+
.not.toBe('');
37+
const normalized = normalizeUiErrorText((await generatorPage.schema.errorStatus.textContent())?.trim() || '');
38+
scenario.assertAcceptance(expect, normalized);
39+
}
40+
41+
expectNoPageErrors(pageErrors);
42+
});
43+
}
44+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Bad schema: the constraint contradicts the literal value.
2+
# All surfaces should reject this during validation/generation.
3+
Status
4+
literal(closed)
5+
6+
IF [Status] = "closed" THEN [Status] = "open" ENDIF
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Bad schema: the field name is present but the rule definition is missing.
2+
# All surfaces should reject this and surface a "requires a data definition"
3+
# style validation error.
4+
OnlyName
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Good schema: autoIncrement.sequence should generate a deterministic
2+
# sequence when start, step, prefix, suffix, and zero padding are fixed.
3+
Ticket
4+
autoIncrement.sequence(start=1, step=5, prefix="T-", suffix=".txt", zeropadding=3)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Good schema: autoIncrement.timestamp should generate deterministic
2+
# timestamps when the start time, step, type, and output format are fixed.
3+
Created At
4+
autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=2, type="days", outputFormat="yyyy-MM-dd")
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Good schema: comments and blank lines should be ignored consistently.
2+
# Priority is intentionally enum-based so acceptance checks validate
3+
# allowed values instead of one exact row ordering.
4+
Priority
5+
enum(high,medium,low)
6+
7+
# This literal column gives one stable value across all rows.
8+
Status
9+
literal(active)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Good schema: deterministic literals so every surface should generate
2+
# the same headers, row count, and values.
3+
First Name
4+
literal(Alice)
5+
6+
Status
7+
literal(active)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { createRequire } from 'node:module';
3+
import path from 'node:path';
4+
5+
const CROSS_SURFACE_PROCESS_TIMEOUT_MS = 60000;
6+
7+
const require = createRequire(import.meta.url);
8+
const {
9+
repoRoot,
10+
SCHEMA_ACCEPTANCE_SCENARIOS,
11+
} = require('./support/schema-acceptance-fixtures.cjs');
12+
const {
13+
normalizeCliSuccess,
14+
normalizeCliFailure,
15+
normalizeMcpPayload,
16+
} = require('./support/schema-acceptance-assertions.cjs');
17+
18+
function jsonRpcMessages(output) {
19+
return output
20+
.split(/\r?\n/)
21+
.map((line) => line.trim())
22+
.filter((line) => line.startsWith('{'))
23+
.map((line) => {
24+
try {
25+
return JSON.parse(line);
26+
} catch {
27+
return null;
28+
}
29+
})
30+
.filter(Boolean);
31+
}
32+
33+
function requestMcpServer(payload) {
34+
const scriptPath = path.join(repoRoot, 'apps', 'mcp', 'src', 'index.js');
35+
const output = execFileSync(process.execPath, [scriptPath], {
36+
input: `${JSON.stringify(payload)}\n`,
37+
encoding: 'utf8',
38+
cwd: repoRoot,
39+
// Full-suite runs can be CPU-bound; keep the cross-surface contract stable under load.
40+
timeout: CROSS_SURFACE_PROCESS_TIMEOUT_MS,
41+
});
42+
const messages = jsonRpcMessages(output);
43+
const response = messages.find((message) => message?.id === payload.id);
44+
expect(response).toBeTruthy();
45+
return response;
46+
}
47+
48+
function parseMcpScenarioPayload(response, scenarioId) {
49+
if (response?.error) {
50+
const code = response.error.code ?? 'unknown';
51+
const message = response.error.message || JSON.stringify(response.error);
52+
throw new Error(
53+
`MCP JSON-RPC error for schema acceptance scenario "${scenarioId}" [${code}]: ${message}`
54+
);
55+
}
56+
57+
const text = response?.result?.content?.[0]?.text;
58+
if (typeof text !== 'string' || text.trim() === '') {
59+
throw new Error(
60+
`MCP returned no result payload for schema acceptance scenario "${scenarioId}".`
61+
);
62+
}
63+
64+
try {
65+
return JSON.parse(text);
66+
} catch (error) {
67+
throw new Error(
68+
`MCP returned non-JSON payload for schema acceptance scenario "${scenarioId}": ${error.message}`
69+
);
70+
}
71+
}
72+
73+
function runCliScenario(scenario) {
74+
const cliEntry = path.join(repoRoot, 'apps', 'cli', 'src', 'node-entry.js');
75+
76+
let stdout;
77+
try {
78+
stdout = execFileSync(
79+
process.execPath,
80+
[
81+
cliEntry,
82+
'generate',
83+
'-i',
84+
scenario.schemaPath,
85+
'-n',
86+
String(scenario.rowCount),
87+
'-f',
88+
scenario.outputFormat,
89+
'--show-progress',
90+
'false',
91+
],
92+
{
93+
cwd: repoRoot,
94+
encoding: 'utf8',
95+
// Full-suite runs can be CPU-bound; keep the cross-surface contract stable under load.
96+
timeout: CROSS_SURFACE_PROCESS_TIMEOUT_MS,
97+
}
98+
);
99+
} catch (error) {
100+
return normalizeCliFailure(error.stderr || error.stdout || error.message);
101+
}
102+
103+
try {
104+
return normalizeCliSuccess(stdout, scenario.expectedHeaders);
105+
} catch (error) {
106+
throw new Error(
107+
`CLI returned non-JSON output for schema acceptance scenario "${scenario.id}": ${error.message}`
108+
);
109+
}
110+
}
111+
112+
describe('cross-surface schema acceptance (CLI + MCP)', () => {
113+
test.each(SCHEMA_ACCEPTANCE_SCENARIOS)('$id CLI matches shared acceptance criteria', (scenario) => {
114+
const normalized = runCliScenario(scenario);
115+
scenario.assertAcceptance(expect, normalized);
116+
});
117+
118+
test.each(SCHEMA_ACCEPTANCE_SCENARIOS)('$id MCP matches shared acceptance criteria', (scenario) => {
119+
const response = requestMcpServer({
120+
jsonrpc: '2.0',
121+
id: `schema-acceptance-${scenario.id}`,
122+
method: 'tools/call',
123+
params: {
124+
name: 'generate_data_from_spec',
125+
arguments: {
126+
textSpec: scenario.schemaText,
127+
rowCount: scenario.rowCount,
128+
outputFormat: scenario.outputFormat,
129+
},
130+
},
131+
});
132+
const payload = parseMcpScenarioPayload(response, scenario.id);
133+
const normalized = normalizeMcpPayload(payload);
134+
scenario.assertAcceptance(expect, normalized);
135+
});
136+
});

0 commit comments

Comments
 (0)