11import { readdir , readFile } from 'node:fs/promises'
2- import { deflateRawSync } from 'node:zlib'
2+ import { deflateRawSync , inflateRawSync } from 'node:zlib'
33import { 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+
6773const playerState = ( page : Page ) => page . locator ( 'h5p-player' ) . evaluate ( ( element : HTMLElement & { state ?: string } ) => element . state )
6874
6975async 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+
387529test ( '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
426568test ( '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+
442605test ( '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 ( ) )
0 commit comments