Skip to content

Commit a8711ee

Browse files
committed
The source arrives laid out, and a link that carried it follows it
The Format button goes: a source that arrives is laid out and taken out of its code fence, so nobody has to ask. A source opened from a link or a file, and one pasted over all of the text (or into an empty editor), arrives that way; a paste into the text, and text that is not JSON, go in as they are. Where the editor cannot load, the textarea lays a whole paste out too. A page opened by a link that carried a source (#source=) now keeps the link in step: when the source changes to another JSON source, the address is replaced, not added to the history, with the new source on one line, deflated where the browser can. A change of layout, and text that is not JSON, leave it as it was; an emptied editor leaves #source= with nothing after it, so the next source is written; a link that cannot be read gives way to the source the person writes. A page opened with no source in its address never writes one. formatSource moves to json-scan.ts, which the editor's chunk can use without the author package.
1 parent e726331 commit a8711ee

7 files changed

Lines changed: 313 additions & 56 deletions

File tree

‎e2e/build.spec.ts‎

Lines changed: 173 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readdir, readFile } from 'node:fs/promises'
2-
import { deflateRawSync } from 'node:zlib'
2+
import { deflateRawSync, inflateRawSync } from 'node:zlib'
33
import { expect, test, type APIRequestContext, type Page } from '@playwright/test'
44

55
/*
@@ -64,6 +64,12 @@ async function fillSource(page: Page, text: string): Promise<void> {
6464
await expect(page.locator('#source')).toHaveValue(text)
6565
}
6666

67+
/** Pastes text through the clipboard into what has the focus, as a person does: the browser's own paste. */
68+
async function paste(page: Page, text: string): Promise<void> {
69+
await page.evaluate((value) => navigator.clipboard.writeText(value), text)
70+
await page.keyboard.press('ControlOrMeta+v')
71+
}
72+
6773
const playerState = (page: Page) => page.locator('h5p-player').evaluate((element: HTMLElement & { state?: string }) => element.state)
6874

6975
async function expectPlays(page: Page): Promise<void> {
@@ -282,7 +288,8 @@ test('a link carrying the source builds it at once, deflated or not', async ({ p
282288
const deflated = deflateRawSync(Buffer.from(JSON.stringify(RIVERS))).toString('base64url')
283289
await page.goto(`/h5p/build/#source=${deflated}`)
284290
await expectPlays(page)
285-
await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS))
291+
// It arrives laid out, a field to a line, where the link carried it on one.
292+
await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS, null, 2))
286293

287294
// Plain base64url, with umlauts and ß: the bytes are UTF-8, and the file name keeps the letters.
288295
const plain = Buffer.from(JSON.stringify({ ...RIVERS, language: 'de', title: 'Größte Flüsse' })).toString('base64url')
@@ -368,22 +375,157 @@ test('a mistake in the JSON is marked as it is typed, with its line and column,
368375
expect(problems, problems.join('\n')).toEqual([])
369376
})
370377

371-
test('Format lays the source out, and takes it out of the code fence a chat put it in', async ({ page, baseURL }) => {
378+
test('a source pasted whole arrives laid out, out of its code fence; any other paste goes in as it is', async ({ page, context, baseURL }) => {
372379
const { problems } = watch(page, baseURL)
380+
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
373381
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.')
382+
const field = page.locator('#source')
383+
const lakes = { ...RIVERS, title: 'Lakes' }
384+
await page.locator('.source-editor .cm-content').click()
385+
// Into the empty editor, on one line in a code fence, as a chat may hand it over.
386+
await paste(page, '```json\n' + JSON.stringify(RIVERS) + '\n```')
387+
await expect(field).toHaveValue(JSON.stringify(RIVERS, null, 2))
378388
await expect(page.locator('.source-editor .cm-line').first()).toHaveText('{')
389+
// Over all of the text, too.
390+
await page.keyboard.press('ControlOrMeta+a')
391+
await paste(page, JSON.stringify(lakes))
392+
await expect(field).toHaveValue(JSON.stringify(lakes, null, 2))
393+
// Into the text it is the person's own to lay out: it goes in as it is.
394+
await page.keyboard.press('ControlOrMeta+End')
395+
await paste(page, '{"a":1}')
396+
await expect(field).toHaveValue(JSON.stringify(lakes, null, 2) + '{"a":1}')
379397
// 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()
398+
await page.keyboard.press('ControlOrMeta+a')
399+
await paste(page, '{ "type": ')
400+
await expect(field).toHaveValue('{ "type": ')
401+
await expect(page.locator('#source-state')).toContainText('The text ends before the JSON is complete')
402+
expect(problems, problems.join('\n')).toEqual([])
403+
})
404+
405+
test('a source opened from a file arrives laid out, and there is no button to lay it out', async ({ page, baseURL }) => {
406+
const { problems } = watch(page, baseURL)
407+
await page.goto('/h5p/build/')
408+
await expect(page.getByRole('button', { name: 'Format' })).toHaveCount(0)
409+
await page.locator('#source-file').setInputFiles({ name: 'rivers.json', mimeType: 'application/json', buffer: Buffer.from('```json\n' + JSON.stringify(RIVERS) + '\n```') })
410+
await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS, null, 2))
411+
await expect(page.locator('.source-editor .cm-line').first()).toHaveText('{')
412+
// A file that is not JSON is as it is, with the mistake marked.
413+
await page.locator('#source-file').setInputFiles({ name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('{ "type": ') })
382414
await expect(page.locator('#source')).toHaveValue('{ "type": ')
383415
await expect(page.locator('#source-state')).toContainText('The text ends before the JSON is complete')
384416
expect(problems, problems.join('\n')).toEqual([])
385417
})
386418

419+
/** The source an address carries, as the text of its bytes, plain or deflated: `''` for an empty one, `null` for none or for bytes that are neither. */
420+
function carriedBy(page: Page): string | null {
421+
const value = new URLSearchParams(new URL(page.url()).hash.slice(1)).get('source')
422+
if (value === null) return null
423+
const bytes = Buffer.from(value, 'base64url')
424+
if (!bytes.length) return ''
425+
const text = bytes.toString('utf8')
426+
try {
427+
JSON.parse(text)
428+
return text
429+
} catch {
430+
try {
431+
return inflateRawSync(bytes).toString('utf8')
432+
} catch {
433+
return null
434+
}
435+
}
436+
}
437+
438+
/** Counts the page's writes to its address, from before the page's own scripts run. */
439+
const countAddressWrites = (page: Page) => page.addInitScript(() => {
440+
const counter = window as unknown as { addressWrites: number }
441+
counter.addressWrites = 0
442+
const replaceState = history.replaceState.bind(history)
443+
history.replaceState = (...args: Parameters<History['replaceState']>) => {
444+
counter.addressWrites++
445+
return replaceState(...args)
446+
}
447+
})
448+
const addressWrites = (page: Page) => page.evaluate(() => (window as unknown as { addressWrites: number }).addressWrites)
449+
450+
test('a link that carried the source follows it as it is changed', async ({ page, baseURL }) => {
451+
const { problems } = watch(page, baseURL)
452+
await countAddressWrites(page)
453+
await page.goto(`/h5p/build/#source=${deflateRawSync(Buffer.from(JSON.stringify(RIVERS))).toString('base64url')}`)
454+
await expectPlays(page)
455+
// Arriving writes nothing: the address already carries the source that is shown.
456+
await page.waitForTimeout(900)
457+
expect(await addressWrites(page)).toBe(0)
458+
const entries = await page.evaluate(() => history.length)
459+
460+
// Another source: the address carries it, on one line, and the history has no new entry.
461+
const lakes = { ...RIVERS, title: 'Lakes' }
462+
await fillSource(page, JSON.stringify(lakes, null, 2))
463+
await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(lakes))
464+
expect(await addressWrites(page)).toBe(1)
465+
expect(await page.evaluate(() => history.length), 'replaced, not added').toBe(entries)
466+
467+
// A change of layout is not a change of the source, and text that is not JSON leaves the address as it was.
468+
await page.keyboard.press('ControlOrMeta+End')
469+
await page.keyboard.press('Enter')
470+
await page.waitForTimeout(900)
471+
expect(await addressWrites(page)).toBe(1)
472+
await page.keyboard.type('x')
473+
await expect(page.locator('#source-state')).toContainText('Not valid JSON')
474+
await page.waitForTimeout(900)
475+
expect(await addressWrites(page)).toBe(1)
476+
expect(carriedBy(page)).toBe(JSON.stringify(lakes))
477+
await page.keyboard.press('Backspace')
478+
479+
// A reload builds what the address carries now.
480+
await page.reload()
481+
await expectPlays(page)
482+
await expect(page.locator('#source')).toHaveValue(JSON.stringify(lakes, null, 2))
483+
await expect(page.locator('#status')).toContainText('Built “Lakes”')
484+
485+
// Emptied, it carries none, and the address is still the link of a page that follows: the next source is written.
486+
await page.locator('.source-editor .cm-content').click()
487+
await page.keyboard.press('ControlOrMeta+a')
488+
await page.keyboard.press('Delete')
489+
await expect.poll(() => carriedBy(page)).toBe('')
490+
const seas = { ...RIVERS, title: 'Seas' }
491+
await fillSource(page, JSON.stringify(seas))
492+
await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(seas))
493+
expect(problems, problems.join('\n')).toEqual([])
494+
})
495+
496+
test('a page opened with no source in its address leaves the address alone', async ({ page, baseURL }) => {
497+
const { problems } = watch(page, baseURL)
498+
await countAddressWrites(page)
499+
await page.goto('/h5p/build/')
500+
await fillSource(page, JSON.stringify(RIVERS))
501+
await page.waitForTimeout(900)
502+
expect(await addressWrites(page)).toBe(0)
503+
expect(new URL(page.url()).hash).toBe('')
504+
expect(problems, problems.join('\n')).toEqual([])
505+
})
506+
507+
test('a link that cannot be read gives way to the source the person writes', async ({ page, baseURL }) => {
508+
const { problems } = watch(page, baseURL)
509+
await page.goto('/h5p/build/#source=not-a-source')
510+
await expect(page.locator('#status')).toContainText('neither JSON nor deflated JSON')
511+
await fillSource(page, JSON.stringify(RIVERS))
512+
await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(RIVERS))
513+
expect(problems, problems.join('\n')).toEqual([])
514+
})
515+
516+
test('where the browser cannot compress, the address carries the JSON as it is', async ({ page, baseURL }) => {
517+
const { problems } = watch(page, baseURL)
518+
await page.addInitScript(() => { Reflect.deleteProperty(window, 'CompressionStream') })
519+
await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify(RIVERS)).toString('base64url')}`)
520+
await expectPlays(page)
521+
const lakes = { ...RIVERS, title: 'Lakes' }
522+
await fillSource(page, JSON.stringify(lakes, null, 2))
523+
await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(lakes))
524+
const value = new URLSearchParams(new URL(page.url()).hash.slice(1)).get('source')!
525+
expect(Buffer.from(value, 'base64url').toString('utf8')).toBe(JSON.stringify(lakes))
526+
expect(problems, problems.join('\n')).toEqual([])
527+
})
528+
387529
test('a problem the build finds is marked where it is in the source, and its name goes there', async ({ page, baseURL }) => {
388530
const { problems } = watch(page, baseURL)
389531
await page.goto('/h5p/build/')
@@ -420,7 +562,7 @@ test('the editor has a name, its label goes to it, and Tab leaves it', async ({
420562
await page.getByText('The source, as JSON', { exact: true }).click()
421563
await expect(editor).toBeFocused()
422564
await page.keyboard.press('Tab')
423-
await expect(page.locator('#format')).toBeFocused()
565+
await expect(page.locator('#source-file')).toBeFocused()
424566
})
425567

426568
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 }) => {
@@ -439,6 +581,27 @@ test('text typed over a selection is the editor’s own, so a bracket wraps it a
439581
expect(problems, problems.join('\n')).toEqual([])
440582
})
441583

584+
test('where the editor cannot load, the textarea lays a whole paste out too, and the address follows it', async ({ page, context }) => {
585+
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
586+
await page.route('**/h5p/assets/editor-*.js', (route) => route.abort())
587+
await page.goto('/h5p/build/')
588+
const field = page.locator('#source')
589+
await field.click()
590+
await paste(page, '```json\n' + JSON.stringify(RIVERS) + '\n```')
591+
await expect(field).toHaveValue(JSON.stringify(RIVERS, null, 2))
592+
// Into the text, as it is.
593+
await field.evaluate((area: HTMLTextAreaElement) => area.setSelectionRange(area.value.length, area.value.length))
594+
await paste(page, '{"a":1}')
595+
await expect(field).toHaveValue(JSON.stringify(RIVERS, null, 2) + '{"a":1}')
596+
// A link that carried the source follows the textarea as it does the editor.
597+
await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify(RIVERS)).toString('base64url')}`)
598+
await expectPlays(page)
599+
await expect(field).toHaveValue(JSON.stringify(RIVERS, null, 2))
600+
const lakes = { ...RIVERS, title: 'Lakes' }
601+
await field.fill(JSON.stringify(lakes))
602+
await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(lakes))
603+
})
604+
442605
test('where the editor cannot load, the source is the textarea, and the page works', async ({ page, baseURL }) => {
443606
const { elsewhere } = watch(page, baseURL)
444607
await page.route('**/h5p/assets/editor-*.js', (route) => route.abort())

‎src/assets/site.css‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,8 @@ tbody th a:hover code { text-decoration: underline; }
586586
.build textarea[hidden] { display: none; }
587587
.build .field { margin: 1rem 0 0.5rem; }
588588
.source-editor { display: block; }
589-
.build .source-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.9rem; margin: 0.6rem 0 0.4rem; }
590-
.build .source-tools .button { font-size: 0.875rem; padding: 0.25rem 0.8rem; }
591-
.build #source-state { margin: 0; }
589+
/* A line of its own, kept when it is empty, so that what is under it does not move as a mistake comes and goes. */
590+
.build #source-state { margin: 0.5rem 0 0.25rem; min-height: 1.5em; }
592591
.build #source-state[data-state="error"] { color: var(--bad); }
593592
.build .link-button, .build .problems .jump {
594593
padding: 0; border: 0; background: none; color: var(--accent); font: inherit; text-decoration: underline; cursor: pointer;

0 commit comments

Comments
 (0)