Skip to content

Commit c2c257e

Browse files
committed
A JSON editor on the build page, where the textarea only shows the source
1 parent d89e423 commit c2c257e

10 files changed

Lines changed: 1037 additions & 32 deletions

File tree

‎e2e/build.spec.ts‎

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ const copied = async (page: Page): Promise<string> => {
5151
return page.evaluate(() => navigator.clipboard.readText())
5252
}
5353

54+
/**
55+
* Fills the source. It is edited in an editor, which loads apart and takes the textarea's place;
56+
* the textarea stays under it holding the same text, which is how the page reads the source. The
57+
* text is typed over a selection of all of it, made by the editor's own key: a page selection that
58+
* a test makes itself can be taken back by the editor, which keeps its own.
59+
*/
60+
async function fillSource(page: Page, text: string): Promise<void> {
61+
await page.locator('.source-editor .cm-content').click()
62+
await page.keyboard.press('ControlOrMeta+a')
63+
await page.keyboard.insertText(text)
64+
await expect(page.locator('#source')).toHaveValue(text)
65+
}
66+
5467
const playerState = (page: Page) => page.locator('h5p-player').evaluate((element: HTMLElement & { state?: string }) => element.state)
5568

5669
async function expectPlays(page: Page): Promise<void> {
@@ -67,7 +80,7 @@ test('a pasted source builds, plays and downloads', async ({ page, baseURL }) =>
6780
await expect(page.locator('[data-theme-toggle]')).toBeVisible()
6881
await expect(page.locator('#files-step')).toBeHidden()
6982
// A chat hands the source in a code fence; the page takes what is inside it.
70-
await page.locator('#source').fill('```json\n' + JSON.stringify(RIVERS, null, 2) + '\n```')
83+
await fillSource(page, '```json\n' + JSON.stringify(RIVERS, null, 2) + '\n```')
7184
await page.locator('#build').click()
7285
await expectPlays(page)
7386
await expect(page.locator('#status')).toContainText('Built “Rivers”: H5P.Blanks, ')
@@ -96,7 +109,7 @@ test('a source with problems gets them all listed, ready for the chat', async ({
96109
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
97110
await page.goto('/h5p/build/')
98111
const { title: _title, ...untitled } = RIVERS
99-
await page.locator('#source').fill(JSON.stringify({ ...untitled, colour: 'blue' }))
112+
await fillSource(page, JSON.stringify({ ...untitled, colour: 'blue' }))
100113
await page.locator('#build').click()
101114
await expect(page.locator('#status')).toHaveAttribute('data-state', 'error')
102115
const listed = page.locator('#problems li')
@@ -115,7 +128,7 @@ test('a source with problems gets them all listed, ready for the chat', async ({
115128
expect(files).toBe(await typeFile(request, 'H5P.Blanks'))
116129

117130
// Text that is not JSON is a problem of its own, said where it is, and names no content type.
118-
await page.locator('#source').fill('{ "type": "H5P.Blanks", ')
131+
await fillSource(page, '{ "type": "H5P.Blanks", ')
119132
await page.locator('#build').click()
120133
await expect(listed).toHaveCount(1)
121134
await expect(listed.first()).toContainText('is not valid JSON')
@@ -144,7 +157,7 @@ test('the copy carries the type files of the source’s type and of the librarie
144157
}
145158
}
146159
await page.goto('/h5p/build/')
147-
await page.locator('#source').fill(JSON.stringify(book))
160+
await fillSource(page, JSON.stringify(book))
148161
await page.locator('#build').click()
149162
await expect(page.locator('#status')).toHaveAttribute('data-state', 'error')
150163
// The build says what to write, as h5p-author does since 0.7.0.
@@ -156,7 +169,7 @@ test('the copy carries the type files of the source’s type and of the librarie
156169
expect(files).toBe([await typeFile(request, 'H5P.InteractiveBook'), await typeFile(request, 'H5P.AdvancedText'), await typeFile(request, 'H5P.MultiChoice')].join('\n\n---\n\n'))
157170

158171
// A type the pack does not have has no file: its problems are copied alone.
159-
await page.locator('#source').fill(JSON.stringify({ ...book, type: 'H5P.ImageChoice', params: {} }))
172+
await fillSource(page, JSON.stringify({ ...book, type: 'H5P.ImageChoice', params: {} }))
160173
await page.locator('#build').click()
161174
await expect(page.locator('#problems li')).not.toHaveCount(0)
162175
const alone = await copied(page)
@@ -173,7 +186,7 @@ test('the copy carries the type file of a type named as it is spoken of, or unde
173186
/** The type files a source's copy carries after its problems, once a problem of it is shown. */
174187
const filesOf = async (source: unknown, said: string): Promise<string | undefined> => {
175188
await page.goto('/h5p/build/')
176-
await page.locator('#source').fill(JSON.stringify(source))
189+
await fillSource(page, JSON.stringify(source))
177190
await page.locator('#build').click()
178191
await expect(page.locator('#problems')).toContainText(said)
179192
return (await copied(page)).split(`\n\n${TYPES_HEADING}\n\n`)[1]
@@ -209,7 +222,7 @@ test('the problems are copied alone where the type files cannot be fetched, and
209222
await page.route('**/h5p/build/types/**', (route) => route.fulfill({ status: 503, body: 'unavailable' }))
210223
await page.goto('/h5p/build/')
211224
const { title: _title, ...untitled } = RIVERS
212-
await page.locator('#source').fill(JSON.stringify(untitled))
225+
await fillSource(page, JSON.stringify(untitled))
213226
await page.locator('#build').click()
214227
await expect(page.locator('#problems li')).toHaveCount(1)
215228
expect(await copied(page)).toMatch(/^The build page found 1 problem in the source\. Fix them all and write the whole source again:\n- title: [^\n]*$/)
@@ -231,11 +244,11 @@ test('type files that arrive once newer problems are shown are not copied with t
231244
})
232245
await page.goto('/h5p/build/')
233246
const { title: _title, ...untitled } = RIVERS
234-
await page.locator('#source').fill(JSON.stringify(untitled))
247+
await fillSource(page, JSON.stringify(untitled))
235248
await page.locator('#build').click()
236249
await expect(page.locator('#problems li code')).toHaveText('title')
237250
// Its type file is on its way when the next source's problems replace these: text that names no type.
238-
await page.locator('#source').fill('{ "type": ')
251+
await fillSource(page, '{ "type": ')
239252
await page.locator('#build').click()
240253
await expect(page.locator('#problems li')).toContainText('is not valid JSON')
241254
const arrived = page.waitForResponse('**/h5p/build/types/H5P.Blanks.md')
@@ -333,3 +346,111 @@ test('a source that names a file waits for it, then builds with it', async ({ pa
333346
await expect.poll(() => page.frameLocator('h5p-player iframe').first().locator('img').first().evaluate((img: HTMLImageElement) => img.naturalWidth)).toBe(2)
334347
expect(problems, problems.join('\n')).toEqual([])
335348
})
349+
350+
// The source's editor: CodeMirror, loaded apart, in place of the textarea, which stays under it.
351+
352+
test('a mistake in the JSON is marked as it is typed, with its line and column, and goes when it is put right', async ({ page, baseURL }) => {
353+
const { problems } = watch(page, baseURL)
354+
await page.goto('/h5p/build/')
355+
const state = page.locator('#source-state')
356+
await expect(state).toHaveText('')
357+
await fillSource(page, '{\n "type": "H5P.Blanks"\n "title": "Rivers"\n}')
358+
// Said in words, with where, in the line under the source and in the editor itself.
359+
await expect(state).toContainText('Not valid JSON at line 3, column 3: A comma is missing here')
360+
await expect(page.locator('.source-editor .cm-lintRange-error')).toHaveCount(1)
361+
// The button goes where the mistake is.
362+
await state.getByRole('button', { name: 'Show where' }).click()
363+
await expect(page.locator('.source-editor .cm-activeLine')).toContainText('"title": "Rivers"')
364+
// Put right, it goes.
365+
await fillSource(page, JSON.stringify(RIVERS))
366+
await expect(state).toHaveText('')
367+
await expect(page.locator('.source-editor .cm-lintRange-error')).toHaveCount(0)
368+
expect(problems, problems.join('\n')).toEqual([])
369+
})
370+
371+
test('Format lays the source out, and takes it out of the code fence a chat put it in', async ({ page, baseURL }) => {
372+
const { problems } = watch(page, baseURL)
373+
await page.goto('/h5p/build/')
374+
await fillSource(page, '```json\n' + JSON.stringify(RIVERS) + '\n```')
375+
await page.locator('#format').click()
376+
await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS, null, 2))
377+
await expect(page.locator('#source-state')).toHaveText('Laid out.')
378+
await expect(page.locator('.source-editor .cm-line').first()).toHaveText('{')
379+
// Text that is not JSON is left as it is, and the line under the source says where.
380+
await fillSource(page, '{ "type": ')
381+
await page.locator('#format').click()
382+
await expect(page.locator('#source')).toHaveValue('{ "type": ')
383+
await expect(page.locator('#source-state')).toContainText('The text ends before the JSON is complete')
384+
expect(problems, problems.join('\n')).toEqual([])
385+
})
386+
387+
test('a problem the build finds is marked where it is in the source, and its name goes there', async ({ page, baseURL }) => {
388+
const { problems } = watch(page, baseURL)
389+
await page.goto('/h5p/build/')
390+
const { title: _title, ...untitled } = RIVERS
391+
await fillSource(page, JSON.stringify({ ...untitled, colour: 'blue' }, null, 2))
392+
await page.locator('#build').click()
393+
await expect(page.locator('#status')).toHaveAttribute('data-state', 'error')
394+
// The field that is not a field of a source, name and value; the one that is missing, in the object it is missing from.
395+
const marks = page.locator('.source-editor .cm-lintRange-error')
396+
await expect(marks).toHaveCount(2)
397+
await expect(marks.filter({ hasText: '"colour": "blue"' })).toHaveCount(1)
398+
// Each problem's name goes to its place.
399+
await page.locator('#problems button.jump', { hasText: /^colour$/ }).click()
400+
await expect(page.locator('.source-editor .cm-activeLine')).toContainText('"colour": "blue"')
401+
await page.locator('#problems button.jump', { hasText: /^title$/ }).click()
402+
await expect(page.locator('.source-editor .cm-activeLine')).toHaveText('{')
403+
// The marks are of the text as it was built: a change to it clears them.
404+
await page.keyboard.press('ArrowRight')
405+
await page.keyboard.type(' ')
406+
await expect(marks).toHaveCount(0)
407+
// Text that is not JSON is a problem at a place of its own.
408+
await fillSource(page, '{ "type": "H5P.Blanks" "title": 1 }')
409+
await page.locator('#build').click()
410+
await expect(page.locator('#problems li')).toContainText('is not valid JSON at line 1, column 24: A comma is missing here')
411+
await page.locator('#problems button.jump').click()
412+
await expect(page.locator('.source-editor .cm-activeLine')).toContainText('"title": 1')
413+
expect(problems, problems.join('\n')).toEqual([])
414+
})
415+
416+
test('the editor has a name, its label goes to it, and Tab leaves it', async ({ page }) => {
417+
await page.goto('/h5p/build/')
418+
const editor = page.getByRole('textbox', { name: 'The source, as JSON' })
419+
await expect(editor).toBeVisible()
420+
await page.getByText('The source, as JSON', { exact: true }).click()
421+
await expect(editor).toBeFocused()
422+
await page.keyboard.press('Tab')
423+
await expect(page.locator('#format')).toBeFocused()
424+
})
425+
426+
test('text typed over a selection is the editor’s own, so a bracket wraps it and the policy is not asked', async ({ page, baseURL }) => {
427+
const { problems } = watch(page, baseURL)
428+
await page.goto('/h5p/build/')
429+
await fillSource(page, JSON.stringify(RIVERS, null, 2))
430+
await page.locator('.source-editor .cm-content').click()
431+
await page.keyboard.press('ControlOrMeta+a')
432+
await page.keyboard.type('[')
433+
await expect(page.locator('#source')).toHaveValue(`[${JSON.stringify(RIVERS, null, 2)}]`)
434+
await page.keyboard.press('ControlOrMeta+a')
435+
await page.keyboard.type('x')
436+
await expect(page.locator('#source')).toHaveValue('x')
437+
await page.keyboard.press('ControlOrMeta+z')
438+
await expect(page.locator('#source')).toHaveValue(`[${JSON.stringify(RIVERS, null, 2)}]`)
439+
expect(problems, problems.join('\n')).toEqual([])
440+
})
441+
442+
test('where the editor cannot load, the source is the textarea, and the page works', async ({ page, baseURL }) => {
443+
const { elsewhere } = watch(page, baseURL)
444+
await page.route('**/h5p/assets/editor-*.js', (route) => route.abort())
445+
await page.goto('/h5p/build/')
446+
const field = page.locator('#source')
447+
await expect(field).toBeVisible()
448+
await field.fill(JSON.stringify({ ...RIVERS, authors: [] }))
449+
await page.locator('#build').click()
450+
await expect(page.locator('#problems li code')).toHaveText('authors')
451+
// Its name selects where it is, in the textarea.
452+
await page.locator('#problems button.jump').click()
453+
expect(await field.evaluate((area: HTMLTextAreaElement) => area.value.slice(area.selectionStart, area.selectionEnd))).toBe('"authors":[')
454+
await expect(page.locator('.source-editor')).toHaveCount(0)
455+
expect(elsewhere).toEqual([])
456+
})

‎e2e/json-scan.spec.ts‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { expect, test } from '@playwright/test'
2+
import { jsonError, lineColumn, locate } from '../src/h5p/build/json-scan'
3+
4+
/*
5+
* The reading of JSON text behind the build page's editor, with no browser: where a text stops being
6+
* JSON and what is wrong there, and where a problem's path points. The page's own tests (build.spec.ts)
7+
* show both in the editor.
8+
*/
9+
10+
test('JSON, and text with nothing in it, has no mistake', () => {
11+
for (const text of ['{}', '[]', ' { "a": [1, -2.5e+3, 0, true, false, null, "x\\n\\u00e9\\"", {}] } \n', '"text"', '0', '', ' \n']) {
12+
expect(jsonError(text), JSON.stringify(text)).toBeNull()
13+
}
14+
})
15+
16+
test('a mistake is found where it is, and said in words for someone who does not write JSON', () => {
17+
/** The text with a marker, `¦`, at the offset the mistake is expected at, taken out. */
18+
const cases: [string, RegExp][] = [
19+
['{ "a": 1 ¦"b": 2 }', /comma is missing/],
20+
['{ "a": [1, 2 ¦}', /Expected "," or "\]"/],
21+
['{ "a": 1¦, }', /comma right before a closing bracket/],
22+
['[1, 2¦,]', /comma right before a closing bracket/],
23+
['{ "a": 1¦', /text ends before the JSON is complete/],
24+
['{ ¦\'a\': 1 }', /not in 'single quotes'/],
25+
['{ "a": ¦True }', /"True" is not JSON/],
26+
['{ "a": ¦nope }', /"nope" is not JSON/],
27+
['{ "a": "line¦\nbreak" }', /line break inside text in quotes/],
28+
['{ "a": ¦01 }', /not a valid number/],
29+
['{ "a": ¦1.5.2 }', /not a valid number/],
30+
['{ "a": 1 } ¦extra', /more text after the end/],
31+
['{ "a": ¦// note\n 1 }', /no comments/],
32+
['{ "a" ¦1 }', /Expected ":" after the name/],
33+
['{ "a": ¦}', /Expected a value here/],
34+
['{ "a": ¦"x', /not closed/],
35+
['{ "a": "x¦\\q" }', /A backslash starts an escape/],
36+
['{ "a": "x¦\\u12" }', /four hexadecimal digits/],
37+
['{ ¦a: 1 }', /Expected a name in "double quotes" here, but found "a"/],
38+
['{ "a": 1, ¦, "b": 2 }', /Expected a name in "double quotes" here, but found ","/]
39+
]
40+
for (const [marked, message] of cases) {
41+
const offset = marked.indexOf('¦')
42+
const text = marked.replace('¦', '')
43+
const error = jsonError(text)
44+
expect(error, text).not.toBeNull()
45+
expect(error!.offset, text).toBe(offset)
46+
expect(error!.message, text).toMatch(message)
47+
}
48+
})
49+
50+
test('what is not JSON for JSON.parse is not for this reading either', () => {
51+
for (const text of ['{ "a": 1 "b": 2 }', '{ "a": [1, 2 }', "{ 'a': 1 }", '{ "a": True }', '{ "a": 01 }', '{ "a": 1 } x', '{ "a": 1, }', '[1,]', '{"a" 1}', '{', '[', '"x', '{"a":"\n"}']) {
52+
expect(() => JSON.parse(text), text).toThrow()
53+
expect(jsonError(text), text).not.toBeNull()
54+
}
55+
for (const text of ['{"a":[1,2,{"b":null}]}', '[]', '{"a":"\\u00e9\\n"}', '-0.5E-3', '"
"']) {
56+
expect(() => JSON.parse(text), text).not.toThrow()
57+
expect(jsonError(text), text).toBeNull()
58+
}
59+
})
60+
61+
test('a text nested too deeply is a mistake, not a crash', () => {
62+
const error = jsonError('['.repeat(1000))
63+
expect(error?.message).toMatch(/nested too deeply/)
64+
})
65+
66+
test('an offset is a line and a column, each from 1', () => {
67+
const text = '{\n "a": 1\n "b": 2\n}'
68+
const error = jsonError(text)
69+
expect(lineColumn(text, error!.offset)).toEqual({ line: 3, column: 3 })
70+
expect(lineColumn('', 0)).toEqual({ line: 1, column: 1 })
71+
expect(lineColumn('abc', 3)).toEqual({ line: 1, column: 4 })
72+
})
73+
74+
test('a problem’s path points at its name and value, or at what it is missing from', () => {
75+
const source = JSON.stringify({ type: 'H5P.Blanks', title: 'Rivers', authors: [], params: { questions: ['a', 'b'] } }, null, 2)
76+
const at = (path: string): { text: string; exact: boolean } | null => {
77+
const found = locate(source, path)
78+
return found && { text: source.slice(found.from, found.to), exact: found.exact }
79+
}
80+
// A scalar whole, with its name; an object or an array by its name and its opening bracket.
81+
expect(at('title')).toEqual({ text: '"title": "Rivers"', exact: true })
82+
expect(at('params.questions[1]')).toEqual({ text: '"b"', exact: true })
83+
expect(at('params')).toEqual({ text: '"params": {', exact: true })
84+
expect(at('authors')).toEqual({ text: '"authors": [', exact: true })
85+
// What the source has none of is looked for in what it is missing from.
86+
expect(at('colour')).toEqual({ text: '{', exact: false })
87+
expect(at('params.questions[5]')).toEqual({ text: '"questions": [', exact: false })
88+
expect(at('params.missing.deep')).toEqual({ text: '"params": {', exact: false })
89+
expect(at('title.inside')).toEqual({ text: '"title": "Rivers"', exact: false })
90+
// A path that cannot be read, and a text that is not JSON, have no place.
91+
expect(at('nope[')).toBeNull()
92+
expect(at('')).toBeNull()
93+
expect(at('a..b')).toBeNull()
94+
expect(locate('{ "title": ', 'title')).toBeNull()
95+
})
96+
97+
test('a name with an escape in it is found by what it says', () => {
98+
const text = '{ "caf\\u00e9": { "x": 1 } }'
99+
const found = locate(text, 'café.x')
100+
expect(found && text.slice(found.from, found.to)).toBe('"x": 1')
101+
})

‎package.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@
2222
"vite": "8.3.6"
2323
},
2424
"dependencies": {
25+
"@codemirror/autocomplete": "6.20.4",
26+
"@codemirror/commands": "6.11.1",
27+
"@codemirror/lang-json": "6.0.2",
28+
"@codemirror/language": "6.13.1",
29+
"@codemirror/lint": "6.9.7",
30+
"@codemirror/state": "6.7.6",
31+
"@codemirror/view": "6.43.15",
32+
"@lezer/highlight": "1.2.5",
2533
"@missing-elements/h5p-author": "0.9.2",
2634
"@missing-elements/h5p-libraries": "1.1.0",
2735
"@missing-elements/h5p-offline-player": "1.6.0",

0 commit comments

Comments
 (0)