Skip to content

Commit 2f2d272

Browse files
authored
Add Writer API schema test page (#205)
* Add Writer API schema test page * Address writer schema review feedback
1 parent 4925f5a commit 2f2d272

8 files changed

Lines changed: 1620 additions & 12 deletions

File tree

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import { jest } from '@jest/globals';
2+
import { JSDOM } from 'jsdom';
3+
import {
4+
DEFAULT_PROMPT,
5+
bootstrapWriterSchemaPage,
6+
buildWriterSharedContext,
7+
extractJsonPayload,
8+
normalizeStructuredSchemaPayload,
9+
parseWriterStructuredOutput,
10+
runWriterSchemaGeneration,
11+
} from '../../writer-schema-page.mjs';
12+
13+
describe('writer schema prototype page', () => {
14+
let dom;
15+
16+
beforeEach(() => {
17+
dom = new JSDOM(
18+
`<!doctype html><html><body>
19+
<div class="header" data-role="theme-toggle-host"><div class="pageheading">AnyWayData</div></div>
20+
<main id="writer-schema-page-root">
21+
<p id="writer-schema-support-status">Checking Writer API availability...</p>
22+
<textarea id="writer-schema-prompt"></textarea>
23+
<button id="writer-schema-example-prompt" type="button">Load example prompt</button>
24+
<button id="writer-schema-generate" type="button">Generate schema from prompt</button>
25+
<p id="writer-schema-generation-status">Ready for a prompt.</p>
26+
<pre id="writer-schema-json-output">No output yet.</pre>
27+
<pre id="writer-schema-request-output">No Writer request has been sent yet.</pre>
28+
<pre id="writer-schema-raw-output">No raw Writer response yet.</pre>
29+
<pre id="writer-schema-error-output">No errors yet.</pre>
30+
<ol id="writer-schema-progress-output"><li>No generation activity yet.</li></ol>
31+
<div id="writer-schema-editor-root"></div>
32+
</main>
33+
</body></html>`,
34+
{ url: 'https://example.test/writer-schema.html' }
35+
);
36+
});
37+
38+
afterEach(() => {
39+
dom.window.close();
40+
jest.restoreAllMocks();
41+
});
42+
43+
test('extractJsonPayload unwraps fenced JSON', () => {
44+
expect(extractJsonPayload('```json\n{"schemaFields":[]}\n```')).toBe('{"schemaFields":[]}');
45+
});
46+
47+
test('parseWriterStructuredOutput parses JSON objects from plain text responses', () => {
48+
expect(
49+
parseWriterStructuredOutput('Result:\n{"schemaFields":[{"name":"Title","sourceType":"literal","value":"book"}]}')
50+
).toEqual({
51+
schemaFields: [{ name: 'Title', sourceType: 'literal', value: 'book' }],
52+
});
53+
});
54+
55+
test('normalizeStructuredSchemaPayload maps supported source types into schema rows', () => {
56+
expect(
57+
normalizeStructuredSchemaPayload({
58+
schemaFields: [
59+
{ name: 'Category', sourceType: 'enum', values: ['A', 'B'] },
60+
{ name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' },
61+
{ name: 'ISBN', sourceType: 'regex', pattern: '[0-9]{3}' },
62+
],
63+
})
64+
).toMatchObject({
65+
schemaRows: [
66+
{ name: 'Category', sourceType: 'enum', value: '"A","B"' },
67+
{ name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' },
68+
{ name: 'ISBN', sourceType: 'regex', value: '[0-9]{3}' },
69+
],
70+
normalizationErrors: [],
71+
});
72+
});
73+
74+
test('buildWriterSharedContext includes schema guidance and allowed domain commands', () => {
75+
const context = buildWriterSharedContext({
76+
domainCommands: ['person.fullName', 'commerce.productName'],
77+
sampleSchemaText: 'Name\nperson.fullName',
78+
});
79+
80+
expect(context).toContain('Supported sourceType values are exactly: domain, enum, literal, regex.');
81+
expect(context).toContain('Never invent or rename commands');
82+
expect(context).toContain('Do not use literal placeholders like YYYY-MM-DD');
83+
expect(context).toContain('person.fullName, commerce.productName');
84+
expect(context).toContain('Name\nperson.fullName');
85+
});
86+
87+
test('normalizeStructuredSchemaPayload rejects invented domain commands', () => {
88+
const result = normalizeStructuredSchemaPayload(
89+
{
90+
schemaFields: [
91+
{ name: 'Author Name', sourceType: 'domain', command: 'person.fullName' },
92+
{ name: 'Publisher', sourceType: 'domain', command: 'commerce.publisher' },
93+
],
94+
},
95+
{
96+
allowedDomainCommands: ['book.publisher', 'commerce.productName', 'person.fullName'],
97+
}
98+
);
99+
100+
expect(result.schemaRows).toMatchObject([{ name: 'Author Name', command: 'person.fullName' }]);
101+
expect(result.normalizationErrors).toHaveLength(1);
102+
expect(result.normalizationErrors[0].message).toContain('unsupported command "commerce.publisher"');
103+
});
104+
105+
test('normalizeStructuredSchemaPayload rejects date placeholder literals for date-like fields', () => {
106+
expect(() =>
107+
normalizeStructuredSchemaPayload({
108+
schemaFields: [{ name: 'Published Date', sourceType: 'literal', value: 'YYYY-MM-DD' }],
109+
})
110+
).toThrow('Use an allowed date.* domain command instead');
111+
});
112+
113+
test('runWriterSchemaGeneration parses structured JSON text returned by Writer', async () => {
114+
const writer = {
115+
destroy: jest.fn(),
116+
write: jest.fn(async () =>
117+
JSON.stringify({
118+
schemaFields: [
119+
{ name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' },
120+
{ name: 'Genre', sourceType: 'enum', values: ['Fiction', 'Non-fiction'] },
121+
],
122+
})
123+
),
124+
};
125+
const WriterCtor = {
126+
create: jest.fn(async () => writer),
127+
};
128+
129+
const result = await runWriterSchemaGeneration({
130+
WriterCtor,
131+
promptText: DEFAULT_PROMPT,
132+
domainCommands: ['commerce.productName', 'person.fullName'],
133+
sampleSchemaText: 'Name\nperson.fullName',
134+
onStatus: jest.fn(),
135+
});
136+
137+
expect(WriterCtor.create).toHaveBeenCalledTimes(1);
138+
expect(WriterCtor.create).toHaveBeenCalledWith(
139+
expect.objectContaining({
140+
expectedInputLanguages: ['en'],
141+
expectedContextLanguages: ['en'],
142+
outputLanguage: 'en',
143+
})
144+
);
145+
expect(writer.write).toHaveBeenCalledWith(
146+
DEFAULT_PROMPT,
147+
expect.objectContaining({
148+
context: expect.any(String),
149+
expectedInputLanguages: ['en'],
150+
expectedContextLanguages: ['en'],
151+
outputLanguage: 'en',
152+
})
153+
);
154+
expect(result.parsedPayload.schemaFields).toHaveLength(2);
155+
expect(result.requestDetails).toMatchObject({
156+
promptText: DEFAULT_PROMPT,
157+
taskContext: expect.any(String),
158+
writeOptions: expect.objectContaining({
159+
outputLanguage: 'en',
160+
}),
161+
createOptions: expect.objectContaining({
162+
sharedContext: expect.any(String),
163+
}),
164+
});
165+
expect(result.schemaRows).toMatchObject([
166+
{ name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' },
167+
{ name: 'Genre', sourceType: 'enum', value: '"Fiction","Non-fiction"' },
168+
]);
169+
expect(result.normalizationErrors).toEqual([]);
170+
expect(writer.destroy).toHaveBeenCalledTimes(1);
171+
});
172+
173+
test('bootstrap warns when Writer API support is unavailable', async () => {
174+
const schemaComponent = {
175+
destroy: jest.fn(),
176+
replaceRows: jest.fn(),
177+
setTextMode: jest.fn(),
178+
render: jest.fn(),
179+
syncTextFromRows: jest.fn(),
180+
validateRows: jest.fn(() => ({ errors: [] })),
181+
getSchemaText: jest.fn(() => ''),
182+
};
183+
184+
await bootstrapWriterSchemaPage({
185+
documentObj: dom.window.document,
186+
WriterCtor: null,
187+
createThemeToggleComponentFn: () => ({ destroy: jest.fn() }),
188+
createSharedSchemaDefinitionComponentFn: () => schemaComponent,
189+
});
190+
191+
expect(dom.window.document.getElementById('writer-schema-support-status').textContent).toContain(
192+
'Writer API is not available'
193+
);
194+
expect(dom.window.document.getElementById('writer-schema-prompt').value).toBe(DEFAULT_PROMPT);
195+
});
196+
197+
test('bootstrap generates rows and populates the shared schema component', async () => {
198+
const schemaComponent = {
199+
destroy: jest.fn(),
200+
replaceRows: jest.fn(),
201+
setTextMode: jest.fn(),
202+
render: jest.fn(),
203+
syncTextFromRows: jest.fn(),
204+
validateRows: jest.fn(() => ({ errors: [] })),
205+
getSchemaText: jest.fn(() => 'Book Title\ncommerce.productName()'),
206+
};
207+
const writer = {
208+
write: jest.fn(async () =>
209+
JSON.stringify({
210+
schemaFields: [{ name: 'Book Title', sourceType: 'domain', command: 'commerce.productName' }],
211+
})
212+
),
213+
};
214+
const WriterCtor = {
215+
availability: jest.fn(async () => 'available'),
216+
create: jest.fn(async () => writer),
217+
};
218+
219+
const page = await bootstrapWriterSchemaPage({
220+
documentObj: dom.window.document,
221+
WriterCtor,
222+
createThemeToggleComponentFn: () => ({ destroy: jest.fn() }),
223+
createSharedSchemaDefinitionComponentFn: () => schemaComponent,
224+
});
225+
226+
await page.generateFromPrompt();
227+
228+
expect(schemaComponent.replaceRows).toHaveBeenCalledWith(
229+
expect.arrayContaining([expect.objectContaining({ name: 'Book Title', command: 'commerce.productName' })])
230+
);
231+
expect(schemaComponent.setTextMode.mock.invocationCallOrder[0]).toBeLessThan(
232+
schemaComponent.replaceRows.mock.invocationCallOrder[0]
233+
);
234+
expect(schemaComponent.syncTextFromRows).toHaveBeenCalledTimes(1);
235+
expect(dom.window.document.getElementById('writer-schema-generation-status').textContent).toContain(
236+
'Generated 1 schema fields'
237+
);
238+
expect(dom.window.document.getElementById('writer-schema-json-output').textContent).toContain('Book Title');
239+
expect(dom.window.document.getElementById('writer-schema-request-output').textContent).toContain(
240+
'"promptText": "Create 10 fields that represent the inventory of a bookshop"'
241+
);
242+
expect(dom.window.document.getElementById('writer-schema-raw-output').textContent).toContain(
243+
'commerce.productName'
244+
);
245+
expect(dom.window.document.getElementById('writer-schema-error-output').textContent).toBe('No errors yet.');
246+
expect(dom.window.document.getElementById('writer-schema-progress-output').textContent).toContain(
247+
'Schema generation completed successfully.'
248+
);
249+
});
250+
251+
test('bootstrap shows full error and raw response when generated schema cannot be normalized', async () => {
252+
const schemaComponent = {
253+
destroy: jest.fn(),
254+
replaceRows: jest.fn(),
255+
setTextMode: jest.fn(),
256+
render: jest.fn(),
257+
syncTextFromRows: jest.fn(),
258+
validateRows: jest.fn(() => ({ errors: [] })),
259+
getSchemaText: jest.fn(() => ''),
260+
};
261+
const invalidResponse = JSON.stringify({
262+
schemaFields: [{ name: 'Book Title', sourceType: 'domain' }],
263+
});
264+
const writer = {
265+
write: jest.fn(async () => invalidResponse),
266+
};
267+
const WriterCtor = {
268+
availability: jest.fn(async () => 'available'),
269+
create: jest.fn(async () => writer),
270+
};
271+
272+
const page = await bootstrapWriterSchemaPage({
273+
documentObj: dom.window.document,
274+
WriterCtor,
275+
createThemeToggleComponentFn: () => ({ destroy: jest.fn() }),
276+
createSharedSchemaDefinitionComponentFn: () => schemaComponent,
277+
});
278+
279+
await expect(page.generateFromPrompt()).rejects.toThrow(
280+
'Generated domain field "Book Title" is missing a command.'
281+
);
282+
283+
expect(dom.window.document.getElementById('writer-schema-generation-status').textContent).toContain(
284+
'Unable to generate a schema from the prompt'
285+
);
286+
expect(dom.window.document.getElementById('writer-schema-error-output').textContent).toContain(
287+
'Generated domain field "Book Title" is missing a command.'
288+
);
289+
expect(dom.window.document.getElementById('writer-schema-raw-output').textContent).toContain(
290+
'"sourceType":"domain"'
291+
);
292+
expect(dom.window.document.getElementById('writer-schema-request-output').textContent).toContain(
293+
'"outputLanguage": "en"'
294+
);
295+
expect(dom.window.document.getElementById('writer-schema-progress-output').textContent).toContain(
296+
'Generation failed: Generated domain field "Book Title" is missing a command.'
297+
);
298+
});
299+
300+
test('bootstrap keeps valid schema rows when some generated fields are invalid', async () => {
301+
const schemaComponent = {
302+
destroy: jest.fn(),
303+
replaceRows: jest.fn(),
304+
setTextMode: jest.fn(),
305+
render: jest.fn(),
306+
syncTextFromRows: jest.fn(),
307+
validateRows: jest.fn(() => ({ errors: [] })),
308+
getSchemaText: jest.fn(() => 'Author Name\nperson.fullName()'),
309+
};
310+
const writer = {
311+
write: jest.fn(async () =>
312+
JSON.stringify({
313+
schemaFields: [
314+
{ name: 'Author Name', sourceType: 'domain', command: 'person.fullName' },
315+
{ name: 'Publisher', sourceType: 'domain', command: 'commerce.publisher' },
316+
],
317+
})
318+
),
319+
};
320+
const WriterCtor = {
321+
availability: jest.fn(async () => 'available'),
322+
create: jest.fn(async () => writer),
323+
};
324+
325+
const page = await bootstrapWriterSchemaPage({
326+
documentObj: dom.window.document,
327+
WriterCtor,
328+
createThemeToggleComponentFn: () => ({ destroy: jest.fn() }),
329+
createSharedSchemaDefinitionComponentFn: () => schemaComponent,
330+
});
331+
332+
await page.generateFromPrompt();
333+
334+
expect(schemaComponent.replaceRows).toHaveBeenCalledWith(
335+
expect.arrayContaining([expect.objectContaining({ name: 'Author Name', command: 'person.fullName' })])
336+
);
337+
expect(dom.window.document.getElementById('writer-schema-generation-status').textContent).toContain(
338+
'could not be mapped and were left out'
339+
);
340+
expect(dom.window.document.getElementById('writer-schema-error-output').textContent).toContain(
341+
'unsupported command "commerce.publisher"'
342+
);
343+
expect(dom.window.document.getElementById('writer-schema-progress-output').textContent).toContain(
344+
'Completed with partial recovery.'
345+
);
346+
});
347+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
void import('./writer-schema-page.mjs')
2+
.then(({ bootstrapWriterSchemaPage }) => bootstrapWriterSchemaPage())
3+
.catch((error) => {
4+
globalThis.console?.error?.('Failed to load Writer schema prototype page', error);
5+
});

0 commit comments

Comments
 (0)