From e1c74e2496d6571e472163f11c8ea73281b35d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Mon, 16 Sep 2024 13:42:28 +0000 Subject: [PATCH 01/10] feat: sync files from WebContainer to editor --- packages/astro/src/default/utils/content.ts | 1 + packages/runtime/README.md | 5 +- packages/runtime/src/index.ts | 2 - packages/runtime/src/store/editor.ts | 2 +- packages/runtime/src/store/index.ts | 6 +- .../src/{ => store}/tutorial-runner.spec.ts | 6 +- .../src/{ => store}/tutorial-runner.ts | 138 ++++++++++++++++-- packages/runtime/src/utils/multi-counter.ts | 27 ++++ packages/runtime/src/utils/promises.ts | 2 +- .../src/content/tutorial/1-basics/meta.md | 2 + packages/types/src/schemas/common.ts | 14 ++ 11 files changed, 184 insertions(+), 21 deletions(-) rename packages/runtime/src/{ => store}/tutorial-runner.spec.ts (95%) rename packages/runtime/src/{ => store}/tutorial-runner.ts (78%) create mode 100644 packages/runtime/src/utils/multi-counter.ts diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts index 68d2cb55e..fc01f13f6 100644 --- a/packages/astro/src/default/utils/content.ts +++ b/packages/astro/src/default/utils/content.ts @@ -248,6 +248,7 @@ export async function getTutorial(): Promise { 'i18n', 'editPageLink', 'openInStackBlitz', + 'filesystem', ], ), }; diff --git a/packages/runtime/README.md b/packages/runtime/README.md index ee44a8124..46e41bafe 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -4,10 +4,9 @@ A wrapper around the **[WebContainer API][webcontainer-api]** focused on providi The runtime exposes the following: -- `lessonFilesFetcher`: A singleton that lets you fetch the contents of the lesson files -- `TutorialRunner`: The API to manage your tutorial content in WebContainer +- `TutorialStore`: A store to manage your tutorial content in WebContainer and in your components. -Only a single instance of `TutorialRunner` should be created in your application and its lifetime is bound by the lifetime of the WebContainer instance. +Only a single instance of `TutorialStore` should be created in your application and its lifetime is bound by the lifetime of the WebContainer instance. ## License diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 36a2bae32..7af9c49bc 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,5 +1,3 @@ -export { LessonFilesFetcher } from './lesson-files.js'; -export { TutorialRunner } from './tutorial-runner.js'; export type { Command, Commands, PreviewInfo, Step, Steps } from './webcontainer/index.js'; export { safeBoot } from './webcontainer/index.js'; export { TutorialStore } from './store/index.js'; diff --git a/packages/runtime/src/store/editor.ts b/packages/runtime/src/store/editor.ts index 89fec3dc3..a0f305d63 100644 --- a/packages/runtime/src/store/editor.ts +++ b/packages/runtime/src/store/editor.ts @@ -113,7 +113,7 @@ export class EditorStore { }); } - updateFile(filePath: string, content: string): boolean { + updateFile(filePath: string, content: string | Uint8Array): boolean { const documentState = this.documents.get()[filePath]; if (!documentState) { diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts index 0db454e24..f0ae00fcf 100644 --- a/packages/runtime/src/store/index.ts +++ b/packages/runtime/src/store/index.ts @@ -3,7 +3,7 @@ import type { WebContainer } from '@webcontainer/api'; import { atom, type ReadableAtom } from 'nanostores'; import { LessonFilesFetcher } from '../lesson-files.js'; import { newTask, type Task } from '../tasks.js'; -import { TutorialRunner } from '../tutorial-runner.js'; +import { TutorialRunner } from './tutorial-runner.js'; import type { ITerminal } from '../utils/terminal.js'; import { bootStatus, unblockBoot, type BootStatus } from '../webcontainer/on-demand-boot.js'; import type { PreviewInfo } from '../webcontainer/preview-info.js'; @@ -59,7 +59,7 @@ export class TutorialStore { this._lessonFilesFetcher = new LessonFilesFetcher(basePathname); this._previewsStore = new PreviewsStore(this._webcontainer); this._terminalStore = new TerminalStore(this._webcontainer, useAuth); - this._runner = new TutorialRunner(this._webcontainer, this._terminalStore, this._stepController); + this._runner = new TutorialRunner(this._webcontainer, this._terminalStore, this._editorStore, this._stepController); /** * By having this code under `import.meta.hot`, it gets: @@ -150,6 +150,8 @@ export class TutorialStore { return; } + this._runner.setSyncChangesFromWebContainer(lesson.data.filesystem?.syncChanges ?? false); + this._lessonTask = newTask( async (signal) => { const templatePromise = this._lessonFilesFetcher.getLessonTemplate(lesson); diff --git a/packages/runtime/src/tutorial-runner.spec.ts b/packages/runtime/src/store/tutorial-runner.spec.ts similarity index 95% rename from packages/runtime/src/tutorial-runner.spec.ts rename to packages/runtime/src/store/tutorial-runner.spec.ts index ae69208ae..a8c608ba2 100644 --- a/packages/runtime/src/tutorial-runner.spec.ts +++ b/packages/runtime/src/store/tutorial-runner.spec.ts @@ -4,10 +4,10 @@ import { resetProcessFactory, setProcessFactory } from '@tutorialkit/test-utils' import { WebContainer } from '@webcontainer/api'; import type { MockedWebContainer } from '@tutorialkit/test-utils'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { TerminalStore } from './store/terminal.js'; +import { TerminalStore } from './terminal.js'; import { TutorialRunner } from './tutorial-runner.js'; -import { withResolvers } from './utils/promises.js'; -import { StepsController } from './webcontainer/steps.js'; +import { withResolvers } from '../utils/promises.js'; +import { StepsController } from '../webcontainer/steps.js'; beforeEach(() => { resetProcessFactory(); diff --git a/packages/runtime/src/tutorial-runner.ts b/packages/runtime/src/store/tutorial-runner.ts similarity index 78% rename from packages/runtime/src/tutorial-runner.ts rename to packages/runtime/src/store/tutorial-runner.ts index 3f9f16fc9..9653c281b 100644 --- a/packages/runtime/src/tutorial-runner.ts +++ b/packages/runtime/src/store/tutorial-runner.ts @@ -1,11 +1,13 @@ import type { CommandsSchema, Files } from '@tutorialkit/types'; -import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; -import type { TerminalStore } from './store/terminal.js'; -import { newTask, type Task, type TaskCancelled } from './tasks.js'; -import { clearTerminal, escapeCodes, type ITerminal } from './utils/terminal.js'; -import { Command, Commands } from './webcontainer/command.js'; -import { StepsController } from './webcontainer/steps.js'; -import { diffFiles, toFileTree } from './webcontainer/utils/files.js'; +import type { WebContainer, WebContainerProcess, IFSWatcher } from '@webcontainer/api'; +import type { TerminalStore } from './terminal.js'; +import { newTask, type Task, type TaskCancelled } from '../tasks.js'; +import { clearTerminal, escapeCodes, type ITerminal } from '../utils/terminal.js'; +import { Command, Commands } from '../webcontainer/command.js'; +import { StepsController } from '../webcontainer/steps.js'; +import { diffFiles, toFileTree } from '../webcontainer/utils/files.js'; +import type { EditorStore } from './editor.js'; +import { MultiCounter } from '../utils/multi-counter.js'; interface LoadFilesOptions { /** @@ -60,6 +62,12 @@ export class TutorialRunner { private _currentTemplate: Files | undefined = undefined; private _currentFiles: Files | undefined = undefined; private _currentRunCommands: Commands | undefined = undefined; + + private _ignoreFileEvents = new MultiCounter(); + private _watcher: IFSWatcher | undefined; + private _syncContentFromWebContainer = false; + private _readyToWatch = false; + private _packageJsonDirty = false; private _commandsChanged = false; @@ -70,9 +78,20 @@ export class TutorialRunner { constructor( private _webcontainer: Promise, private _terminalStore: TerminalStore, + private _editorStore: EditorStore, private _stepController: StepsController, ) {} + setSyncChangesFromWebContainer(value: boolean) { + this._syncContentFromWebContainer = value; + + if (this._readyToWatch && this._syncContentFromWebContainer) { + this._webcontainer.then((webcontainer) => this._setupWatcher(webcontainer)); + } else if (!this._syncContentFromWebContainer) { + this._stopWatcher(); + } + } + /** * Set the commands to run. This updates the reported `steps` if any have changed. * @@ -109,6 +128,8 @@ export class TutorialRunner { signal.throwIfAborted(); + this._ignoreFileEvents.increment(folderPath); + await webcontainer.fs.mkdir(folderPath); }, { ignoreCancel: true }, @@ -132,6 +153,8 @@ export class TutorialRunner { signal.throwIfAborted(); + this._ignoreFileEvents.increment(filePath); + await webcontainer.fs.writeFile(filePath, content); this._updateCurrentFiles({ [filePath]: content }); @@ -156,6 +179,8 @@ export class TutorialRunner { signal.throwIfAborted(); + this._ignoreFileEvents.increment(Object.keys(files)); + await webcontainer.mount(toFileTree(files)); this._updateCurrentFiles(files); @@ -185,6 +210,12 @@ export class TutorialRunner { async (signal) => { await previousLoadPromise; + // no watcher should be installed + this._readyToWatch = false; + + // stop current watcher if they are any + this._stopWatcher(); + const webcontainer = await this._webcontainer; signal.throwIfAborted(); @@ -375,7 +406,7 @@ export class TutorialRunner { const abortListener = () => this._currentCommandProcess?.kill(); signal.addEventListener('abort', abortListener, { once: true }); - const hasMainCommand = !!commands.mainCommand; + let shouldClearDirtyFlag = true; try { const commandList = [...commands]; @@ -419,6 +450,9 @@ export class TutorialRunner { } if (isMainCommand) { + shouldClearDirtyFlag = false; + + this._setupWatcher(webcontainer); this._clearDirtyState(); } @@ -431,6 +465,13 @@ export class TutorialRunner { }); this._stepController.skipRemaining(index + 1); + + /** + * We don't clear the dirty flag in that case as there was an error and re-running all commands + * is the probably better than not running anything. + */ + shouldClearDirtyFlag = false; + break; } else { this._stepController.updateStep(index, { @@ -447,9 +488,17 @@ export class TutorialRunner { } } - if (!hasMainCommand) { + /** + * All commands were run but we didn't clear the dirty state. + * We have to, otherwise we would re-run those commands when moving + * to a lesson that has the exact same set of commands. + */ + if (shouldClearDirtyFlag) { this._clearDirtyState(); } + + // make sure the watcher is configured + this._setupWatcher(webcontainer); } finally { signal.removeEventListener('abort', abortListener); } @@ -503,6 +552,77 @@ export class TutorialRunner { this._updateDirtyState(files); } + private _stopWatcher(): void { + // if there was a watcher terminate it + if (this._watcher) { + this._watcher.close(); + this._watcher = undefined; + } + } + + private _setupWatcher(webcontainer: WebContainer) { + // inform that the watcher could be installed if we wanted to + this._readyToWatch = true; + + // if the watcher is alreay setup or we don't sync content we exit + if (this._watcher || !this._syncContentFromWebContainer) { + return; + } + + const filesToRead = new Map(); + + let timeoutId: ReturnType | undefined; + + const readFiles = () => { + const files = [...filesToRead.entries()]; + + filesToRead.clear(); + + Promise.all( + files.map(async ([filePath, encoding]) => { + // casts could be removed with an `if` but it feels weird + const content = (await webcontainer.fs.readFile(filePath, encoding as any)) as Uint8Array | string; + + return [filePath, content] as const; + }), + ).then((fileContents) => { + for (const [filePath, content] of fileContents) { + this._editorStore.updateFile(filePath, content); + } + }); + }; + + const scheduleReadFor = (filePath: string, encoding: 'utf-8' | null) => { + filesToRead.set(filePath, encoding); + + clearTimeout(timeoutId); + timeoutId = setTimeout(readFiles, 100); + }; + + this._watcher = webcontainer.fs.watch('.', { recursive: true }, (eventType, filename) => { + const filePath = `/${filename}`; + + // events we should ignore because we caused them in the TutorialRunner + if (!this._ignoreFileEvents.decrement(filePath)) { + return; + } + + // for now we only care about 'change' event + if (eventType !== 'change') { + return; + } + + // we ignore all paths that aren't exposed in the `_editorStore` + const file = this._editorStore.documents.get()[filePath]; + + if (!file) { + return; + } + + scheduleReadFor(filePath, typeof file.value === 'string' ? 'utf-8' : null); + }); + } + private _clearDirtyState() { this._packageJsonDirty = false; } diff --git a/packages/runtime/src/utils/multi-counter.ts b/packages/runtime/src/utils/multi-counter.ts new file mode 100644 index 000000000..99b30e342 --- /dev/null +++ b/packages/runtime/src/utils/multi-counter.ts @@ -0,0 +1,27 @@ +export class MultiCounter { + private _counts = new Map(); + + increment(name: string | string[]) { + if (typeof name === 'string') { + const currentValue = this._counts.get(name) ?? 0; + + this._counts.set(name, currentValue + 1); + + return; + } + + name.forEach((value) => this.increment(value)); + } + + decrement(name: string): boolean { + const currentValue = this._counts.get(name) ?? 0; + + if (currentValue === 0) { + return true; + } + + this._counts.set(name, currentValue - 1); + + return currentValue - 1 === 0; + } +} diff --git a/packages/runtime/src/utils/promises.ts b/packages/runtime/src/utils/promises.ts index ebd1e927c..0bb6baf3b 100644 --- a/packages/runtime/src/utils/promises.ts +++ b/packages/runtime/src/utils/promises.ts @@ -28,7 +28,7 @@ export function wait(ms: number): Promise { * @returns A promise that resolves after the tick. */ export function tick() { - return new Promise((resolve) => { + return new Promise((resolve) => { setTimeout(resolve); }); } diff --git a/packages/template/src/content/tutorial/1-basics/meta.md b/packages/template/src/content/tutorial/1-basics/meta.md index 92180c79b..d722e5514 100644 --- a/packages/template/src/content/tutorial/1-basics/meta.md +++ b/packages/template/src/content/tutorial/1-basics/meta.md @@ -1,4 +1,6 @@ --- type: part title: Basics +filesystem: + syncChanges: true --- diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index b1473cb8c..8af91a937 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -55,6 +55,15 @@ export const previewSchema = z.union([ export type PreviewSchema = z.infer; +export const fileSystemSchema = z.object({ + syncChanges: z + .boolean() + .optional() + .describe('When set to true, when a file is changed in WebContainer, it is updated in the editor as well.'), +}); + +export type FileSystemSchema = z.infer; + const panelTypeSchema = z .union([z.literal('output'), z.literal('terminal')]) .describe(`The type of the terminal which can either be 'output' or 'terminal'.`); @@ -200,6 +209,11 @@ export const webcontainerSchema = commandsSchema.extend({ .describe( 'Navigating to a lesson that specifies autoReload will always reload the preview. This is typically only needed if your server does not support HMR.', ), + filesystem: fileSystemSchema + .optional() + .describe( + 'Configure how changes happening on the filesystem should impact the Tutorial. For instance, when new files are being changed, whether those change should be reflected in the editor.', + ), template: z .string() .optional() From 88ed116d7ab37d78b24b43160b0b4bf74afe6e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Mon, 16 Sep 2024 14:42:49 +0000 Subject: [PATCH 02/10] chore(e2e): add e2e test --- e2e/src/components/ButtonWriteToFile.tsx | 12 +++++++++++- .../filesystem-sync/happy-path/_files/bar.txt | 1 + .../filesystem-sync/happy-path/content.mdx | 11 +++++++++++ .../tutorial/tests/filesystem-sync/meta.md | 6 ++++++ e2e/test/filesystem-sync.test.ts | 18 ++++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/bar.txt create mode 100644 e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx create mode 100644 e2e/src/content/tutorial/tests/filesystem-sync/meta.md create mode 100644 e2e/test/filesystem-sync.test.ts diff --git a/e2e/src/components/ButtonWriteToFile.tsx b/e2e/src/components/ButtonWriteToFile.tsx index 5262ce893..c709c7dd2 100644 --- a/e2e/src/components/ButtonWriteToFile.tsx +++ b/e2e/src/components/ButtonWriteToFile.tsx @@ -1,13 +1,23 @@ import tutorialStore from 'tutorialkit:store'; +import { webcontainer } from 'tutorialkit:core'; interface Props { filePath: string; newContent: string; + useWebcontainer?: boolean; testId?: string; } -export function ButtonWriteToFile({ filePath, newContent, testId = 'write-to-file' }: Props) { +export function ButtonWriteToFile({ filePath, newContent, useWebcontainer = false, testId = 'write-to-file' }: Props) { async function writeFile() { + if (useWebcontainer) { + const webcontainerInstance = await webcontainer; + + await webcontainerInstance.fs.writeFile(filePath, newContent); + + return; + } + await new Promise((resolve) => { tutorialStore.lessonFullyLoaded.subscribe((value) => { if (value) { diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/bar.txt b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/bar.txt new file mode 100644 index 000000000..8430408a5 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/bar.txt @@ -0,0 +1 @@ +Initial content diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx new file mode 100644 index 000000000..07171f9d4 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx @@ -0,0 +1,11 @@ +--- +type: lesson +title: Happy path +focus: /bar.txt +--- + +import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; + +# Happy path filesystem test + + diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/meta.md b/e2e/src/content/tutorial/tests/filesystem-sync/meta.md new file mode 100644 index 000000000..484fbd322 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem-sync/meta.md @@ -0,0 +1,6 @@ +--- +type: chapter +title: filesystem.syncChanges +filesystem: + syncChanges: true +--- diff --git a/e2e/test/filesystem-sync.test.ts b/e2e/test/filesystem-sync.test.ts new file mode 100644 index 000000000..c2f58aba2 --- /dev/null +++ b/e2e/test/filesystem-sync.test.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +const BASE_URL = '/tests/filesystem-sync'; + +test('editor should reflect changes made in webcontainer', async ({ page }) => { + const testCase = 'happy-path'; + await page.goto(`${BASE_URL}/${testCase}`); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', { + useInnerText: true, + }); + + await page.getByTestId('write-to-file').click(); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Something else', { + useInnerText: true, + }); +}); From 609a10311d9109a7daa97a7c582a064bee9b7aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Mon, 16 Sep 2024 14:49:30 +0000 Subject: [PATCH 03/10] fix: unit test --- .../runtime/src/store/tutorial-runner.spec.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/store/tutorial-runner.spec.ts b/packages/runtime/src/store/tutorial-runner.spec.ts index a8c608ba2..99eba7949 100644 --- a/packages/runtime/src/store/tutorial-runner.spec.ts +++ b/packages/runtime/src/store/tutorial-runner.spec.ts @@ -1,13 +1,14 @@ // must be imported first import { resetProcessFactory, setProcessFactory } from '@tutorialkit/test-utils'; -import { WebContainer } from '@webcontainer/api'; import type { MockedWebContainer } from '@tutorialkit/test-utils'; +import { WebContainer } from '@webcontainer/api'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { TerminalStore } from './terminal.js'; -import { TutorialRunner } from './tutorial-runner.js'; import { withResolvers } from '../utils/promises.js'; import { StepsController } from '../webcontainer/steps.js'; +import { EditorStore } from './editor.js'; +import { TerminalStore } from './terminal.js'; +import { TutorialRunner } from './tutorial-runner.js'; beforeEach(() => { resetProcessFactory(); @@ -17,7 +18,12 @@ describe('TutorialRunner', () => { test('prepareFiles should mount files to WebContainer', async () => { const webcontainer = WebContainer.boot(); const mock = (await webcontainer) as MockedWebContainer; - const runner = new TutorialRunner(webcontainer, new TerminalStore(webcontainer, false), new StepsController()); + const runner = new TutorialRunner( + webcontainer, + new TerminalStore(webcontainer, false), + new EditorStore(), + new StepsController(), + ); await runner.prepareFiles({ files: { @@ -72,7 +78,12 @@ describe('TutorialRunner', () => { setProcessFactory(processFactory); - const runner = new TutorialRunner(webcontainer, new TerminalStore(webcontainer, false), new StepsController()); + const runner = new TutorialRunner( + webcontainer, + new TerminalStore(webcontainer, false), + new EditorStore(), + new StepsController(), + ); runner.setCommands({ mainCommand: 'some command', From a7eb0d37054958c410804f1f427a2420260743b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Tue, 17 Sep 2024 08:14:18 +0000 Subject: [PATCH 04/10] chore: update docs --- .../content/docs/reference/configuration.mdx | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 4884dce89..5c34a6137 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -218,6 +218,35 @@ type Command = string ``` +##### `filesystem` +Configures how changes such as files being modified or added in WebContainer should be reflected in the editor when they weren't caused by the user directly. By default, the editor will not reflect these changes. + +An example use case is when a user runs a command that modifies a file. For instance when a `package.json` is modified by doing an `npm install `. If `syncChanges` is set to `true`, the file will be updated in the editor. If set to `false`, the file will not be updated. + +This property is by default set to `false` as it can impact performance. If you are creating a lesson where the user is expected to modify files directly, you may want to keep this to `false`. + + + +The `FileSystem` type has the following shape: + +```ts +type FileSystem = { + syncChanges: boolean +} + +``` + +Example values: + +```yaml +filesystem: + syncChanges: true # Filesystem changes are reflected in the editor + +filesystem: + syncChanges: false # Or if it's omitted, the default value is false +``` + + ##### `terminal` Configures one or more terminals. TutorialKit provides two types of terminals: read-only, called `output`, and interactive, called `terminal`. Note, that there can be only one `output` terminal. @@ -277,7 +306,7 @@ Navigating to a lesson that specifies `autoReload` will always reload the previe Specifies which folder from the `src/templates/` directory should be used as the basis for the code. See the "[Code templates](/guides/creating-content/#code-templates)" guide for a detailed explainer. -#### `editPageLink` +##### `editPageLink` Display a link in lesson for editing the page content. The value is a URL pattern where `${path}` is replaced with the lesson's location relative to the `src/content/tutorial`. @@ -304,7 +333,7 @@ You can instruct Github to show the source code instead by adding `plain=1` quer ::: -### `openInStackBlitz` +##### `openInStackBlitz` Display a link for opening current lesson in StackBlitz. From 627c989cfeb63fee221248d952cff3eba0d3beda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Tue, 17 Sep 2024 08:21:11 +0000 Subject: [PATCH 05/10] chore: add test for file in nested folder --- .../happy-path/_files/a/b/baz.txt | 1 + .../filesystem-sync/happy-path/content.mdx | 1 + e2e/test/filesystem-sync.test.ts | 19 ++++++++++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/a/b/baz.txt diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/a/b/baz.txt b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/a/b/baz.txt new file mode 100644 index 000000000..a68818270 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/a/b/baz.txt @@ -0,0 +1 @@ +Baz diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx index 07171f9d4..84d8eba4e 100644 --- a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx +++ b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx @@ -9,3 +9,4 @@ import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; # Happy path filesystem test + diff --git a/e2e/test/filesystem-sync.test.ts b/e2e/test/filesystem-sync.test.ts index c2f58aba2..3cda95972 100644 --- a/e2e/test/filesystem-sync.test.ts +++ b/e2e/test/filesystem-sync.test.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; const BASE_URL = '/tests/filesystem-sync'; -test('editor should reflect changes made in webcontainer', async ({ page }) => { +test('editor should reflect changes made from webcontainer', async ({ page }) => { const testCase = 'happy-path'; await page.goto(`${BASE_URL}/${testCase}`); @@ -16,3 +16,20 @@ test('editor should reflect changes made in webcontainer', async ({ page }) => { useInnerText: true, }); }); + +test('editor should reflect changes made from webcontainer in file in nested folder', async ({ page }) => { + const testCase = 'happy-path'; + await page.goto(`${BASE_URL}/${testCase}`); + + await page.getByRole('button', { name: 'baz.txt' }).click(); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Baz', { + useInnerText: true, + }); + + await page.getByTestId('write-to-file-in-subfolder').click(); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Foo', { + useInnerText: true, + }); +}); From 2a6b5d1a34322d3909e2b05e055f1b12407e389a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Tue, 17 Sep 2024 15:16:28 +0000 Subject: [PATCH 06/10] fix: lint issues --- packages/runtime/src/store/index.ts | 2 +- packages/runtime/src/store/tutorial-runner.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts index b9acbef7d..cea07dd0f 100644 --- a/packages/runtime/src/store/index.ts +++ b/packages/runtime/src/store/index.ts @@ -3,7 +3,6 @@ import type { WebContainer } from '@webcontainer/api'; import { atom, type ReadableAtom } from 'nanostores'; import { LessonFilesFetcher } from '../lesson-files.js'; import { newTask, type Task } from '../tasks.js'; -import { TutorialRunner } from './tutorial-runner.js'; import type { ITerminal } from '../utils/terminal.js'; import type { EditorConfig } from '../webcontainer/editor-config.js'; import { bootStatus, unblockBoot, type BootStatus } from '../webcontainer/on-demand-boot.js'; @@ -13,6 +12,7 @@ import type { TerminalConfig } from '../webcontainer/terminal-config.js'; import { EditorStore, type EditorDocument, type EditorDocuments, type ScrollPosition } from './editor.js'; import { PreviewsStore } from './previews.js'; import { TerminalStore } from './terminal.js'; +import { TutorialRunner } from './tutorial-runner.js'; interface StoreOptions { webcontainer: Promise; diff --git a/packages/runtime/src/store/tutorial-runner.ts b/packages/runtime/src/store/tutorial-runner.ts index 9653c281b..165dae67f 100644 --- a/packages/runtime/src/store/tutorial-runner.ts +++ b/packages/runtime/src/store/tutorial-runner.ts @@ -1,13 +1,13 @@ import type { CommandsSchema, Files } from '@tutorialkit/types'; -import type { WebContainer, WebContainerProcess, IFSWatcher } from '@webcontainer/api'; -import type { TerminalStore } from './terminal.js'; +import type { IFSWatcher, WebContainer, WebContainerProcess } from '@webcontainer/api'; import { newTask, type Task, type TaskCancelled } from '../tasks.js'; +import { MultiCounter } from '../utils/multi-counter.js'; import { clearTerminal, escapeCodes, type ITerminal } from '../utils/terminal.js'; import { Command, Commands } from '../webcontainer/command.js'; import { StepsController } from '../webcontainer/steps.js'; import { diffFiles, toFileTree } from '../webcontainer/utils/files.js'; import type { EditorStore } from './editor.js'; -import { MultiCounter } from '../utils/multi-counter.js'; +import type { TerminalStore } from './terminal.js'; interface LoadFilesOptions { /** From bf3b2f4966eb5bc26f359c87d3b38704f749d1bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Tue, 17 Sep 2024 15:47:05 +0000 Subject: [PATCH 07/10] refactor: syncChanges -> watch --- .../src/content/docs/reference/configuration.mdx | 8 ++++---- .../content/tutorial/tests/filesystem-sync/meta.md | 4 ++-- packages/runtime/src/store/index.ts | 2 +- packages/runtime/src/store/tutorial-runner.ts | 12 ++++++------ .../template/src/content/tutorial/1-basics/meta.md | 2 +- packages/types/src/schemas/common.ts | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 5c34a6137..8e443b08f 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -221,7 +221,7 @@ type Command = string ##### `filesystem` Configures how changes such as files being modified or added in WebContainer should be reflected in the editor when they weren't caused by the user directly. By default, the editor will not reflect these changes. -An example use case is when a user runs a command that modifies a file. For instance when a `package.json` is modified by doing an `npm install `. If `syncChanges` is set to `true`, the file will be updated in the editor. If set to `false`, the file will not be updated. +An example use case is when a user runs a command that modifies a file. For instance when a `package.json` is modified by doing an `npm install `. If `watch` is set to `true`, the file will be updated in the editor. If set to `false`, the file will not be updated. This property is by default set to `false` as it can impact performance. If you are creating a lesson where the user is expected to modify files directly, you may want to keep this to `false`. @@ -231,7 +231,7 @@ The `FileSystem` type has the following shape: ```ts type FileSystem = { - syncChanges: boolean + watch: boolean } ``` @@ -240,10 +240,10 @@ Example values: ```yaml filesystem: - syncChanges: true # Filesystem changes are reflected in the editor + watch: true # Filesystem changes are reflected in the editor filesystem: - syncChanges: false # Or if it's omitted, the default value is false + watch: false # Or if it's omitted, the default value is false ``` diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/meta.md b/e2e/src/content/tutorial/tests/filesystem-sync/meta.md index 484fbd322..3de86ee66 100644 --- a/e2e/src/content/tutorial/tests/filesystem-sync/meta.md +++ b/e2e/src/content/tutorial/tests/filesystem-sync/meta.md @@ -1,6 +1,6 @@ --- type: chapter -title: filesystem.syncChanges +title: filesystem.watch filesystem: - syncChanges: true + watch: true --- diff --git a/packages/runtime/src/store/index.ts b/packages/runtime/src/store/index.ts index cea07dd0f..91efbe405 100644 --- a/packages/runtime/src/store/index.ts +++ b/packages/runtime/src/store/index.ts @@ -150,7 +150,7 @@ export class TutorialStore { return; } - this._runner.setSyncChangesFromWebContainer(lesson.data.filesystem?.syncChanges ?? false); + this._runner.setWatchFromWebContainer(lesson.data.filesystem?.watch ?? false); this._lessonTask = newTask( async (signal) => { diff --git a/packages/runtime/src/store/tutorial-runner.ts b/packages/runtime/src/store/tutorial-runner.ts index 165dae67f..810a6902e 100644 --- a/packages/runtime/src/store/tutorial-runner.ts +++ b/packages/runtime/src/store/tutorial-runner.ts @@ -65,7 +65,7 @@ export class TutorialRunner { private _ignoreFileEvents = new MultiCounter(); private _watcher: IFSWatcher | undefined; - private _syncContentFromWebContainer = false; + private _watchContentFromWebContainer = false; private _readyToWatch = false; private _packageJsonDirty = false; @@ -82,12 +82,12 @@ export class TutorialRunner { private _stepController: StepsController, ) {} - setSyncChangesFromWebContainer(value: boolean) { - this._syncContentFromWebContainer = value; + setWatchFromWebContainer(value: boolean) { + this._watchContentFromWebContainer = value; - if (this._readyToWatch && this._syncContentFromWebContainer) { + if (this._readyToWatch && this._watchContentFromWebContainer) { this._webcontainer.then((webcontainer) => this._setupWatcher(webcontainer)); - } else if (!this._syncContentFromWebContainer) { + } else if (!this._watchContentFromWebContainer) { this._stopWatcher(); } } @@ -565,7 +565,7 @@ export class TutorialRunner { this._readyToWatch = true; // if the watcher is alreay setup or we don't sync content we exit - if (this._watcher || !this._syncContentFromWebContainer) { + if (this._watcher || !this._watchContentFromWebContainer) { return; } diff --git a/packages/template/src/content/tutorial/1-basics/meta.md b/packages/template/src/content/tutorial/1-basics/meta.md index d722e5514..3e201fed6 100644 --- a/packages/template/src/content/tutorial/1-basics/meta.md +++ b/packages/template/src/content/tutorial/1-basics/meta.md @@ -2,5 +2,5 @@ type: part title: Basics filesystem: - syncChanges: true + watch: true --- diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index 8af91a937..5f8ed835d 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -56,7 +56,7 @@ export const previewSchema = z.union([ export type PreviewSchema = z.infer; export const fileSystemSchema = z.object({ - syncChanges: z + watch: z .boolean() .optional() .describe('When set to true, when a file is changed in WebContainer, it is updated in the editor as well.'), From a33f2e61b28157f727f01ae6b6cc06ea1f2f4acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Wed, 18 Sep 2024 14:42:14 +0100 Subject: [PATCH 08/10] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ari Perkkiƶ --- .../src/content/docs/reference/configuration.mdx | 2 +- packages/types/src/schemas/common.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx index 8e443b08f..80e725930 100644 --- a/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx +++ b/docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx @@ -223,7 +223,7 @@ Configures how changes such as files being modified or added in WebContainer sho An example use case is when a user runs a command that modifies a file. For instance when a `package.json` is modified by doing an `npm install `. If `watch` is set to `true`, the file will be updated in the editor. If set to `false`, the file will not be updated. -This property is by default set to `false` as it can impact performance. If you are creating a lesson where the user is expected to modify files directly, you may want to keep this to `false`. +This property is by default set to `false` as it can impact performance. If you are creating a lesson where the user is expected to modify files outside the editor, you may want to keep this to `false`. diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index 5f8ed835d..6d7320b77 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -59,7 +59,7 @@ export const fileSystemSchema = z.object({ watch: z .boolean() .optional() - .describe('When set to true, when a file is changed in WebContainer, it is updated in the editor as well.'), + .describe('When set to true, file changes in WebContainer are updated in the editor as well.'), }); export type FileSystemSchema = z.infer; From a865a1d8e87eb66d1cd0dabf12c90366c893a9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Wed, 18 Sep 2024 13:56:14 +0000 Subject: [PATCH 09/10] fix: code review --- e2e/src/components/ButtonWriteToFile.tsx | 30 +++++++++---------- .../filesystem-sync/happy-path/content.mdx | 12 -------- .../{filesystem-sync => filesystem}/meta.md | 0 .../watch}/_files/a/b/baz.txt | 0 .../watch}/_files/bar.txt | 0 .../tests/filesystem/watch/content.mdx | 12 ++++++++ ...system-sync.test.ts => filesystem.test.ts} | 6 ++-- packages/runtime/src/store/tutorial-runner.ts | 8 +++++ 8 files changed, 37 insertions(+), 31 deletions(-) delete mode 100644 e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx rename e2e/src/content/tutorial/tests/{filesystem-sync => filesystem}/meta.md (100%) rename e2e/src/content/tutorial/tests/{filesystem-sync/happy-path => filesystem/watch}/_files/a/b/baz.txt (100%) rename e2e/src/content/tutorial/tests/{filesystem-sync/happy-path => filesystem/watch}/_files/bar.txt (100%) create mode 100644 e2e/src/content/tutorial/tests/filesystem/watch/content.mdx rename e2e/test/{filesystem-sync.test.ts => filesystem.test.ts} (90%) diff --git a/e2e/src/components/ButtonWriteToFile.tsx b/e2e/src/components/ButtonWriteToFile.tsx index c709c7dd2..e0403e43a 100644 --- a/e2e/src/components/ButtonWriteToFile.tsx +++ b/e2e/src/components/ButtonWriteToFile.tsx @@ -4,29 +4,27 @@ import { webcontainer } from 'tutorialkit:core'; interface Props { filePath: string; newContent: string; - useWebcontainer?: boolean; + + // default to 'store' + access?: 'store' | 'webcontainer'; testId?: string; } -export function ButtonWriteToFile({ filePath, newContent, useWebcontainer = false, testId = 'write-to-file' }: Props) { +export function ButtonWriteToFile({ filePath, newContent, access = 'store', testId = 'write-to-file' }: Props) { async function writeFile() { - if (useWebcontainer) { - const webcontainerInstance = await webcontainer; + switch (access) { + case 'webcontainer': { + const webcontainerInstance = await webcontainer; - await webcontainerInstance.fs.writeFile(filePath, newContent); + await webcontainerInstance.fs.writeFile(filePath, newContent); - return; + return; + } + case 'store': { + tutorialStore.updateFile(filePath, newContent); + return; + } } - - await new Promise((resolve) => { - tutorialStore.lessonFullyLoaded.subscribe((value) => { - if (value) { - resolve(); - } - }); - }); - - tutorialStore.updateFile(filePath, newContent); } return ( diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx b/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx deleted file mode 100644 index 84d8eba4e..000000000 --- a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/content.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -type: lesson -title: Happy path -focus: /bar.txt ---- - -import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; - -# Happy path filesystem test - - - diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/meta.md b/e2e/src/content/tutorial/tests/filesystem/meta.md similarity index 100% rename from e2e/src/content/tutorial/tests/filesystem-sync/meta.md rename to e2e/src/content/tutorial/tests/filesystem/meta.md diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/a/b/baz.txt b/e2e/src/content/tutorial/tests/filesystem/watch/_files/a/b/baz.txt similarity index 100% rename from e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/a/b/baz.txt rename to e2e/src/content/tutorial/tests/filesystem/watch/_files/a/b/baz.txt diff --git a/e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/bar.txt b/e2e/src/content/tutorial/tests/filesystem/watch/_files/bar.txt similarity index 100% rename from e2e/src/content/tutorial/tests/filesystem-sync/happy-path/_files/bar.txt rename to e2e/src/content/tutorial/tests/filesystem/watch/_files/bar.txt diff --git a/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx new file mode 100644 index 000000000..96a3040d4 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx @@ -0,0 +1,12 @@ +--- +type: lesson +title: Watch +focus: /bar.txt +--- + +import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; + +# Watch filesystem test + + + diff --git a/e2e/test/filesystem-sync.test.ts b/e2e/test/filesystem.test.ts similarity index 90% rename from e2e/test/filesystem-sync.test.ts rename to e2e/test/filesystem.test.ts index 3cda95972..f5351adbe 100644 --- a/e2e/test/filesystem-sync.test.ts +++ b/e2e/test/filesystem.test.ts @@ -1,9 +1,9 @@ import { test, expect } from '@playwright/test'; -const BASE_URL = '/tests/filesystem-sync'; +const BASE_URL = '/tests/filesystem'; test('editor should reflect changes made from webcontainer', async ({ page }) => { - const testCase = 'happy-path'; + const testCase = 'watch'; await page.goto(`${BASE_URL}/${testCase}`); await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', { @@ -18,7 +18,7 @@ test('editor should reflect changes made from webcontainer', async ({ page }) => }); test('editor should reflect changes made from webcontainer in file in nested folder', async ({ page }) => { - const testCase = 'happy-path'; + const testCase = 'watch'; await page.goto(`${BASE_URL}/${testCase}`); await page.getByRole('button', { name: 'baz.txt' }).click(); diff --git a/packages/runtime/src/store/tutorial-runner.ts b/packages/runtime/src/store/tutorial-runner.ts index 810a6902e..5f565e632 100644 --- a/packages/runtime/src/store/tutorial-runner.ts +++ b/packages/runtime/src/store/tutorial-runner.ts @@ -592,6 +592,14 @@ export class TutorialRunner { }); }; + /** + * Add a file to the list of files to read and schedule a read for later, effectively debouncing the reads. + * + * This does not cancel any existing requests because those are expected to be completed really + * fast. However every read request allocate memory that needs to be freed. The reason we debounce + * is to avoid running into OOM issues (which has happened in the past) and give time to the GC to + * cleanup the allocated buffers. + */ const scheduleReadFor = (filePath: string, encoding: 'utf-8' | null) => { filesToRead.set(filePath, encoding); From 21a87d398a8e9f5635c8291938491dafec020774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B2an?= Date: Wed, 18 Sep 2024 19:05:54 +0000 Subject: [PATCH 10/10] fix: add test that it does not watch anything if not configured --- .../content/tutorial/tests/filesystem/meta.md | 4 +--- .../tests/filesystem/no-watch/_files/bar.txt | 1 + .../tests/filesystem/no-watch/content.mdx | 11 +++++++++++ .../tutorial/tests/filesystem/watch/content.mdx | 2 ++ e2e/test/filesystem.test.ts | 17 +++++++++++++++++ 5 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 e2e/src/content/tutorial/tests/filesystem/no-watch/_files/bar.txt create mode 100644 e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx diff --git a/e2e/src/content/tutorial/tests/filesystem/meta.md b/e2e/src/content/tutorial/tests/filesystem/meta.md index 3de86ee66..06e99f712 100644 --- a/e2e/src/content/tutorial/tests/filesystem/meta.md +++ b/e2e/src/content/tutorial/tests/filesystem/meta.md @@ -1,6 +1,4 @@ --- type: chapter -title: filesystem.watch -filesystem: - watch: true +title: filesystem --- diff --git a/e2e/src/content/tutorial/tests/filesystem/no-watch/_files/bar.txt b/e2e/src/content/tutorial/tests/filesystem/no-watch/_files/bar.txt new file mode 100644 index 000000000..8430408a5 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/no-watch/_files/bar.txt @@ -0,0 +1 @@ +Initial content diff --git a/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx b/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx new file mode 100644 index 000000000..aeac65eb4 --- /dev/null +++ b/e2e/src/content/tutorial/tests/filesystem/no-watch/content.mdx @@ -0,0 +1,11 @@ +--- +type: lesson +title: No watch +focus: /bar.txt +--- + +import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; + +# Watch filesystem test + + diff --git a/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx index 96a3040d4..46a0ed3b7 100644 --- a/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx +++ b/e2e/src/content/tutorial/tests/filesystem/watch/content.mdx @@ -2,6 +2,8 @@ type: lesson title: Watch focus: /bar.txt +filesystem: + watch: true --- import { ButtonWriteToFile } from '@components/ButtonWriteToFile'; diff --git a/e2e/test/filesystem.test.ts b/e2e/test/filesystem.test.ts index f5351adbe..be14fef7f 100644 --- a/e2e/test/filesystem.test.ts +++ b/e2e/test/filesystem.test.ts @@ -33,3 +33,20 @@ test('editor should reflect changes made from webcontainer in file in nested fol useInnerText: true, }); }); + +test('editor should not reflect changes made from webcontainer if watch is not set', async ({ page }) => { + const testCase = 'no-watch'; + await page.goto(`${BASE_URL}/${testCase}`); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', { + useInnerText: true, + }); + + await page.getByTestId('write-to-file').click(); + + await page.waitForTimeout(1_000); + + await expect(page.getByRole('textbox', { name: 'Editor' })).toHaveText('Initial content\n', { + useInnerText: true, + }); +});