From 4267e0a5fb6a180f50966a17b176e691e3e7c15d Mon Sep 17 00:00:00 2001 From: Christophe Rosset Date: Sat, 12 Jul 2025 09:29:16 +0200 Subject: [PATCH 01/11] chore(web-host): npm init playwright@latest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output of the command: ``` Inside that directory, you can run several commands: npx playwright test Runs the end-to-end tests. npx playwright test --ui Starts the interactive UI mode. npx playwright test --project=chromium Runs the tests only on Desktop Chrome. npx playwright test example Runs the tests in a specific file. npx playwright test --debug Runs the tests in debug mode. npx playwright codegen Auto generate tests with Codegen. We suggest that you begin by typing: npx playwright test And check out the following files: - ./tests/example.spec.ts - Example end-to-end test - ./tests-examples/demo-todo-app.spec.ts - Demo Todo App end-to-end tests - ./playwright.config.ts - Playwright Test configuration Visit https://playwright.dev/docs/intro for more information. ✨ Happy hacking! 🎭 ``` --- package-lock.json | 64 +++ packages/web-host/.gitignore | 6 + packages/web-host/package.json | 109 ++-- packages/web-host/playwright.config.ts | 79 +++ .../tests-examples/demo-todo-app.spec.ts | 489 ++++++++++++++++++ packages/web-host/tests/example.spec.ts | 20 + 6 files changed, 713 insertions(+), 54 deletions(-) create mode 100644 packages/web-host/playwright.config.ts create mode 100644 packages/web-host/tests-examples/demo-todo-app.spec.ts create mode 100644 packages/web-host/tests/example.spec.ts diff --git a/package-lock.json b/package-lock.json index 9e6bf94..c46c0d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1823,6 +1823,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-darwin-arm64": { "version": "1.0.0-beta.19", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.19.tgz", @@ -5515,6 +5531,53 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plugin-echo": { "resolved": "packages/plugin-echo", "link": true @@ -6710,6 +6773,7 @@ }, "devDependencies": { "@bytecodealliance/jco": "^1.11.2", + "@playwright/test": "^1.54.1", "@tailwindcss/vite": "^4.1.11", "@types/node": "^24.0.4", "@types/react": "^19.1.8", diff --git a/packages/web-host/.gitignore b/packages/web-host/.gitignore index a149c6c..9cef249 100644 --- a/packages/web-host/.gitignore +++ b/packages/web-host/.gitignore @@ -26,3 +26,9 @@ dist-ssr # Project specific public/plugins/*.wasm src/wasm/generated + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/packages/web-host/package.json b/packages/web-host/package.json index b39f9f9..0de657b 100644 --- a/packages/web-host/package.json +++ b/packages/web-host/package.json @@ -1,56 +1,57 @@ { - "name": "web-host", - "private": true, - "version": "0.0.0", - "type": "module", - "description": "Web host for Terminal REPL with plugin system (using WebAssembly Component Model)", - "scripts": { - "predev": "npm run prebuild", - "predev:debug": "npm run wit-types && just build-plugins && just build-repl-logic-guest && npm run prepareWasmFiles:debug && npm run wasm:transpile", - "dev": "vite --host", - "dev:debug": "vite --host", - "prebuild": "npm run wit-types && just build-plugins-release && just build-repl-logic-guest-release && npm run prepareWasmFiles:release && npm run wasm:transpile && npm run prepareVirtualFs", - "build": "tsc -b && vite build", - "preview": "vite preview --host", - "lint": "biome check .", - "lint:fix": "biome check --write .", - "typecheck": "tsc --noEmit -p tsconfig.app.json", - "prepareWasmFiles:release": "node --experimental-strip-types --no-warnings ./clis/prepareWasmFiles.ts --mode release", - "prepareWasmFiles:debug": "node --experimental-strip-types --no-warnings ./clis/prepareWasmFiles.ts --mode debug", - "prepareVirtualFs": "node --experimental-strip-types --no-warnings ./clis/prepareFilesystem.ts --path fixtures/filesystem --format ts > src/wasm/virtualFs.ts; biome format --write ./src/wasm/virtualFs.ts", - "wasm:transpile:plugin-echo": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_echo.wasm -o ./src/wasm/generated/plugin_echo/transpiled", - "wasm:transpile:plugin-weather": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_weather.wasm -o ./src/wasm/generated/plugin_weather/transpiled", - "wasm:transpile:plugin-greet": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_greet.wasm -o ./src/wasm/generated/plugin_greet/transpiled", - "wasm:transpile:plugin-ls": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_ls.wasm -o ./src/wasm/generated/plugin_ls/transpiled", - "wasm:transpile:plugin-cat": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_cat.wasm -o ./src/wasm/generated/plugin_cat/transpiled", - "wasm:transpile:repl-logic-guest": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/repl_logic_guest.wasm -o ./src/wasm/generated/repl_logic_guest/transpiled", - "wasm:transpile": "npm run wasm:transpile:plugin-echo && npm run wasm:transpile:plugin-weather && npm run wasm:transpile:plugin-greet && npm run wasm:transpile:plugin-ls && npm run wasm:transpile:plugin-cat && npm run wasm:transpile:repl-logic-guest", - "wit-types:host-api": "jco types --world-name host-api --out-dir ./src/types/generated ../../crates/pluginlab/wit", - "wit-types:plugin-api": "jco types --world-name plugin-api --out-dir ./src/types/generated ../../crates/pluginlab/wit", - "wit-types": "npm run wit-types:clean && npm run wit-types:host-api && npm run wit-types:plugin-api && biome format --write ./src/types/generated", - "wit-types:clean": "rm -rf ./src/types/generated" - }, - "dependencies": { - "clsx": "^2.1.1", - "lucide-react": "^0.525.0", - "qrcode.react": "^4.2.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "tailwind-merge": "^3.3.1", - "zustand": "^5.0.6" - }, - "devDependencies": { - "@bytecodealliance/jco": "^1.11.2", - "@tailwindcss/vite": "^4.1.11", - "@types/node": "^24.0.4", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.5.2", - "commander": "^12.1.0", - "globals": "^16.2.0", - "tailwindcss": "^4.1.11", - "typescript": "~5.8.3", - "typescript-eslint": "^8.34.1", - "vite": "^7.0.0" - } + "name": "web-host", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Web host for Terminal REPL with plugin system (using WebAssembly Component Model)", + "scripts": { + "predev": "npm run prebuild", + "predev:debug": "npm run wit-types && just build-plugins && just build-repl-logic-guest && npm run prepareWasmFiles:debug && npm run wasm:transpile", + "dev": "vite --host", + "dev:debug": "vite --host", + "prebuild": "npm run wit-types && just build-plugins-release && just build-repl-logic-guest-release && npm run prepareWasmFiles:release && npm run wasm:transpile && npm run prepareVirtualFs", + "build": "tsc -b && vite build", + "preview": "vite preview --host", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "typecheck": "tsc --noEmit -p tsconfig.app.json", + "prepareWasmFiles:release": "node --experimental-strip-types --no-warnings ./clis/prepareWasmFiles.ts --mode release", + "prepareWasmFiles:debug": "node --experimental-strip-types --no-warnings ./clis/prepareWasmFiles.ts --mode debug", + "prepareVirtualFs": "node --experimental-strip-types --no-warnings ./clis/prepareFilesystem.ts --path fixtures/filesystem --format ts > src/wasm/virtualFs.ts; biome format --write ./src/wasm/virtualFs.ts", + "wasm:transpile:plugin-echo": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_echo.wasm -o ./src/wasm/generated/plugin_echo/transpiled", + "wasm:transpile:plugin-weather": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_weather.wasm -o ./src/wasm/generated/plugin_weather/transpiled", + "wasm:transpile:plugin-greet": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_greet.wasm -o ./src/wasm/generated/plugin_greet/transpiled", + "wasm:transpile:plugin-ls": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_ls.wasm -o ./src/wasm/generated/plugin_ls/transpiled", + "wasm:transpile:plugin-cat": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_cat.wasm -o ./src/wasm/generated/plugin_cat/transpiled", + "wasm:transpile:repl-logic-guest": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/repl_logic_guest.wasm -o ./src/wasm/generated/repl_logic_guest/transpiled", + "wasm:transpile": "npm run wasm:transpile:plugin-echo && npm run wasm:transpile:plugin-weather && npm run wasm:transpile:plugin-greet && npm run wasm:transpile:plugin-ls && npm run wasm:transpile:plugin-cat && npm run wasm:transpile:repl-logic-guest", + "wit-types:host-api": "jco types --world-name host-api --out-dir ./src/types/generated ../../crates/pluginlab/wit", + "wit-types:plugin-api": "jco types --world-name plugin-api --out-dir ./src/types/generated ../../crates/pluginlab/wit", + "wit-types": "npm run wit-types:clean && npm run wit-types:host-api && npm run wit-types:plugin-api && biome format --write ./src/types/generated", + "wit-types:clean": "rm -rf ./src/types/generated" + }, + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.525.0", + "qrcode.react": "^4.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.6" + }, + "devDependencies": { + "@bytecodealliance/jco": "^1.11.2", + "@playwright/test": "^1.54.1", + "@tailwindcss/vite": "^4.1.11", + "@types/node": "^24.0.4", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "commander": "^12.1.0", + "globals": "^16.2.0", + "tailwindcss": "^4.1.11", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0" + } } diff --git a/packages/web-host/playwright.config.ts b/packages/web-host/playwright.config.ts new file mode 100644 index 0000000..4a0ce3d --- /dev/null +++ b/packages/web-host/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/packages/web-host/tests-examples/demo-todo-app.spec.ts b/packages/web-host/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..164c515 --- /dev/null +++ b/packages/web-host/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,489 @@ +import { expect, type Page, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("https://demo.playwright.dev/todomvc"); +}); + +const TODO_ITEMS = [ + "buy some cheese", + "feed the cat", + "book a doctors appointment", +] as const; + +test.describe("New Todo", () => { + test("should allow me to add todo items", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + // Make sure the list only has one todo item. + await expect(page.getByTestId("todo-title")).toHaveText([TODO_ITEMS[0]]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press("Enter"); + + // Make sure the list now has two todo items. + await expect(page.getByTestId("todo-title")).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1], + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test("should clear text input field when an item is added", async ({ + page, + }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test("should append new items to the bottom of the list", async ({ + page, + }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId("todo-count"); + + // Check test using different methods. + await expect(page.getByText("3 items left")).toBeVisible(); + await expect(todoCount).toHaveText("3 items left"); + await expect(todoCount).toContainText("3"); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId("todo-title")).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe("Mark all as completed", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should allow me to mark all items as completed", async ({ page }) => { + // Complete all todos. + await page.getByLabel("Mark all as complete").check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId("todo-item")).toHaveClass([ + "completed", + "completed", + "completed", + ]); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test("should allow me to clear the complete state of all items", async ({ + page, + }) => { + const toggleAll = page.getByLabel("Mark all as complete"); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId("todo-item")).toHaveClass(["", "", ""]); + }); + + test("complete all checkbox should update state when items are completed / cleared", async ({ + page, + }) => { + const toggleAll = page.getByLabel("Mark all as complete"); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId("todo-item").nth(0); + await firstTodo.getByRole("checkbox").uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe("Item", () => { + test("should allow me to mark items as complete", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + // Check first item. + const firstTodo = page.getByTestId("todo-item").nth(0); + await firstTodo.getByRole("checkbox").check(); + await expect(firstTodo).toHaveClass("completed"); + + // Check second item. + const secondTodo = page.getByTestId("todo-item").nth(1); + await expect(secondTodo).not.toHaveClass("completed"); + await secondTodo.getByRole("checkbox").check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass("completed"); + await expect(secondTodo).toHaveClass("completed"); + }); + + test("should allow me to un-mark items as complete", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + const firstTodo = page.getByTestId("todo-item").nth(0); + const secondTodo = page.getByTestId("todo-item").nth(1); + const firstTodoCheckbox = firstTodo.getByRole("checkbox"); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass("completed"); + await expect(secondTodo).not.toHaveClass("completed"); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass("completed"); + await expect(secondTodo).not.toHaveClass("completed"); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test("should allow me to edit an item", async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId("todo-item"); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole("textbox", { name: "Edit" })).toHaveValue( + TODO_ITEMS[1], + ); + await secondTodo + .getByRole("textbox", { name: "Edit" }) + .fill("buy some sausages"); + await secondTodo.getByRole("textbox", { name: "Edit" }).press("Enter"); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + "buy some sausages", + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); +}); + +test.describe("Editing", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should hide other controls when editing", async ({ page }) => { + const todoItem = page.getByTestId("todo-item").nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole("checkbox")).not.toBeVisible(); + await expect( + todoItem.locator("label", { + hasText: TODO_ITEMS[1], + }), + ).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test("should save edits on blur", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .fill("buy some sausages"); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .dispatchEvent("blur"); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + "buy some sausages", + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); + + test("should trim entered text", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .fill(" buy some sausages "); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .press("Enter"); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + "buy some sausages", + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, "buy some sausages"); + }); + + test("should remove the item if an empty text string was entered", async ({ + page, + }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill(""); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .press("Enter"); + + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should cancel edits on escape", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).dblclick(); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .fill("buy some sausages"); + await todoItems + .nth(1) + .getByRole("textbox", { name: "Edit" }) + .press("Escape"); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe("Counter", () => { + test("should display the current number of todo items", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + // create a todo count locator + const todoCount = page.getByTestId("todo-count"); + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press("Enter"); + + await expect(todoCount).toContainText("1"); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press("Enter"); + await expect(todoCount).toContainText("2"); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe("Clear completed button", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test("should display the correct text", async ({ page }) => { + await page.locator(".todo-list li .toggle").first().check(); + await expect( + page.getByRole("button", { name: "Clear completed" }), + ).toBeVisible(); + }); + + test("should remove completed items when clicked", async ({ page }) => { + const todoItems = page.getByTestId("todo-item"); + await todoItems.nth(1).getByRole("checkbox").check(); + await page.getByRole("button", { name: "Clear completed" }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should be hidden when there are no items that are completed", async ({ + page, + }) => { + await page.locator(".todo-list li .toggle").first().check(); + await page.getByRole("button", { name: "Clear completed" }).click(); + await expect( + page.getByRole("button", { name: "Clear completed" }), + ).toBeHidden(); + }); +}); + +test.describe("Persistence", () => { + test("should persist its data", async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } + + const todoItems = page.getByTestId("todo-item"); + const firstTodoCheck = todoItems.nth(0).getByRole("checkbox"); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(["completed", ""]); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(["completed", ""]); + }); +}); + +test.describe("Routing", () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test("should allow me to display active items", async ({ page }) => { + const todoItem = page.getByTestId("todo-item"); + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Active" }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test("should respect the back button", async ({ page }) => { + const todoItem = page.getByTestId("todo-item"); + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step("Showing all items", async () => { + await page.getByRole("link", { name: "All" }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step("Showing active items", async () => { + await page.getByRole("link", { name: "Active" }).click(); + }); + + await test.step("Showing completed items", async () => { + await page.getByRole("link", { name: "Completed" }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test("should allow me to display completed items", async ({ page }) => { + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Completed" }).click(); + await expect(page.getByTestId("todo-item")).toHaveCount(1); + }); + + test("should allow me to display all items", async ({ page }) => { + await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole("link", { name: "Active" }).click(); + await page.getByRole("link", { name: "Completed" }).click(); + await page.getByRole("link", { name: "All" }).click(); + await expect(page.getByTestId("todo-item")).toHaveCount(3); + }); + + test("should highlight the currently applied filter", async ({ page }) => { + await expect(page.getByRole("link", { name: "All" })).toHaveClass( + "selected", + ); + + //create locators for active and completed links + const activeLink = page.getByRole("link", { name: "Active" }); + const completedLink = page.getByRole("link", { name: "Completed" }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass("selected"); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass("selected"); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder("What needs to be done?"); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press("Enter"); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction((e) => { + return JSON.parse(localStorage["react-todos"]).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage( + page: Page, + expected: number, +) { + return await page.waitForFunction((e) => { + return ( + JSON.parse(localStorage["react-todos"]).filter( + (todo: any) => todo.completed, + ).length === e + ); + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction((t) => { + return JSON.parse(localStorage["react-todos"]) + .map((todo: any) => todo.title) + .includes(t); + }, title); +} diff --git a/packages/web-host/tests/example.spec.ts b/packages/web-host/tests/example.spec.ts new file mode 100644 index 0000000..e05ec8d --- /dev/null +++ b/packages/web-host/tests/example.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; + +test("has title", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test("get started link", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Click the get started link. + await page.getByRole("link", { name: "Get started" }).click(); + + // Expects page to have a heading with the name of Installation. + await expect( + page.getByRole("heading", { name: "Installation" }), + ).toBeVisible(); +}); From 1bdcdb332f960b66d448c17b6cd9b63f84011c75 Mon Sep 17 00:00:00 2001 From: Christophe Rosset Date: Sat, 12 Jul 2025 09:37:48 +0200 Subject: [PATCH 02/11] chore(web-host/playwright): add mobile target --- packages/web-host/playwright.config.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/web-host/playwright.config.ts b/packages/web-host/playwright.config.ts index 4a0ce3d..e055d6a 100644 --- a/packages/web-host/playwright.config.ts +++ b/packages/web-host/playwright.config.ts @@ -38,16 +38,20 @@ export default defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"] }, }, - { - name: "firefox", - use: { ...devices["Desktop Firefox"] }, + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, }, - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, + // { + // name: "firefox", + // use: { ...devices["Desktop Firefox"] }, + // }, + + // { + // name: "webkit", + // use: { ...devices["Desktop Safari"] }, + // }, /* Test against mobile viewports. */ // { From ef93e4a1386f3a5eb7f5f5af301412a17fc914ce Mon Sep 17 00:00:00 2001 From: Christophe Rosset Date: Sat, 12 Jul 2025 18:00:38 +0200 Subject: [PATCH 03/11] test(web-host): e2e tests for navigation --- package.json | 5 ++ packages/web-host/package.json | 5 ++ packages/web-host/playwright.config.ts | 4 +- packages/web-host/tests/example.spec.ts | 20 -------- packages/web-host/tests/navigation.spec.ts | 53 ++++++++++++++++++++++ 5 files changed, 66 insertions(+), 21 deletions(-) delete mode 100644 packages/web-host/tests/example.spec.ts create mode 100644 packages/web-host/tests/navigation.spec.ts diff --git a/package.json b/package.json index f164950..dcd98c9 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,11 @@ "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write .", + "test:e2e:all": "npm run test:e2e:all --workspace=packages/web-host", + "test:e2e:ui": "npm run test:e2e:ui --workspace=packages/web-host", + "test:e2e:all:preview": "npm run test:e2e:all:preview --workspace=packages/web-host", + "test:e2e:ui:preview": "npm run test:e2e:ui:preview --workspace=packages/web-host", + "test:e2e:report": "npm run test:e2e:report --workspace=packages/web-host", "typecheck": "npm run typecheck --workspace=*", "web-host:typecheck": "npm run typecheck --workspace=packages/web-host", "web-host:build": "npm run build --workspace=packages/web-host", diff --git a/packages/web-host/package.json b/packages/web-host/package.json index 0de657b..c432f34 100644 --- a/packages/web-host/package.json +++ b/packages/web-host/package.json @@ -18,6 +18,11 @@ "prepareWasmFiles:release": "node --experimental-strip-types --no-warnings ./clis/prepareWasmFiles.ts --mode release", "prepareWasmFiles:debug": "node --experimental-strip-types --no-warnings ./clis/prepareWasmFiles.ts --mode debug", "prepareVirtualFs": "node --experimental-strip-types --no-warnings ./clis/prepareFilesystem.ts --path fixtures/filesystem --format ts > src/wasm/virtualFs.ts; biome format --write ./src/wasm/virtualFs.ts", + "test:e2e:all": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:all:preview": "BASE_URL=http://localhost:4173/webassembly-component-model-experiments npm run test:e2e:all", + "test:e2e:ui:preview": "BASE_URL=http://localhost:4173/webassembly-component-model-experiments npm run test:e2e:ui", + "test:e2e:report": "playwright show-report", "wasm:transpile:plugin-echo": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_echo.wasm -o ./src/wasm/generated/plugin_echo/transpiled", "wasm:transpile:plugin-weather": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_weather.wasm -o ./src/wasm/generated/plugin_weather/transpiled", "wasm:transpile:plugin-greet": "jco transpile --no-nodejs-compat --no-namespaced-exports public/plugins/plugin_greet.wasm -o ./src/wasm/generated/plugin_greet/transpiled", diff --git a/packages/web-host/playwright.config.ts b/packages/web-host/playwright.config.ts index e055d6a..3d31df0 100644 --- a/packages/web-host/playwright.config.ts +++ b/packages/web-host/playwright.config.ts @@ -26,7 +26,9 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://localhost:3000', + baseURL: + process.env.BASE_URL || + "http://localhost:5173/webassembly-component-model-experiments", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", diff --git a/packages/web-host/tests/example.spec.ts b/packages/web-host/tests/example.spec.ts deleted file mode 100644 index e05ec8d..0000000 --- a/packages/web-host/tests/example.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("has title", async ({ page }) => { - await page.goto("https://playwright.dev/"); - - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); -}); - -test("get started link", async ({ page }) => { - await page.goto("https://playwright.dev/"); - - // Click the get started link. - await page.getByRole("link", { name: "Get started" }).click(); - - // Expects page to have a heading with the name of Installation. - await expect( - page.getByRole("heading", { name: "Installation" }), - ).toBeVisible(); -}); diff --git a/packages/web-host/tests/navigation.spec.ts b/packages/web-host/tests/navigation.spec.ts new file mode 100644 index 0000000..e26eea8 --- /dev/null +++ b/packages/web-host/tests/navigation.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +test("start REPL link", async ({ page }) => { + await page.goto("/"); + await page + .getByRole("button", { name: "✨ Start REPL ✨" }) + .click({ force: true }); + await expect(page).toHaveURL( + "/webassembly-component-model-experiments/#repl", + ); + await expect( + page.getByRole("heading", { name: "REPL Interface" }), + ).toBeVisible(); +}); + +test("direct load repl page", async ({ page }) => { + await page.goto("/#repl"); + await expect( + page.getByRole("heading", { name: "REPL Interface" }), + ).toBeVisible(); +}); + +test("back to home button", async ({ page }) => { + await page.goto("/#repl"); + await page.getByRole("button", { name: " Back to Home" }).click(); + await expect(page).toHaveURL( + "/webassembly-component-model-experiments/#home", + ); + await expect( + page.getByRole("heading", { + name: "WebAssembly Component Model Experiments", + }), + ).toBeVisible(); +}); + +test("back button", async ({ page }) => { + await page.goto("/"); + await page + .getByRole("button", { name: "✨ Start REPL ✨" }) + .click({ force: true }); + await expect(page).toHaveURL( + "/webassembly-component-model-experiments/#repl", + ); + await expect( + page.getByRole("heading", { name: "REPL Interface" }), + ).toBeVisible(); + await page.goBack(); + await expect( + page.getByRole("heading", { + name: "WebAssembly Component Model Experiments", + }), + ).toBeVisible(); +}); From 8d0ca7687a522a183a741bb5f2d6d92bbc8e816b Mon Sep 17 00:00:00 2001 From: Christophe Rosset Date: Sat, 12 Jul 2025 18:57:35 +0200 Subject: [PATCH 04/11] chore(playwright): add to github actions --- .github/workflows/web-host.yml | 11 +++++++++++ README.md | 3 +++ packages/web-host/playwright.config.ts | 11 ++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/web-host.yml b/.github/workflows/web-host.yml index 80e88a8..f210f37 100644 --- a/.github/workflows/web-host.yml +++ b/.github/workflows/web-host.yml @@ -22,6 +22,17 @@ jobs: run: npm ci - name: Build run: npm run web-host:build + - name: Install Playwright + run: npx playwright install --with-deps + working-directory: ./packages/web-host + - name: e2e tests (playwright) + run: WAIT_FOR_SERVER_AT_URL=http://localhost:4173/webassembly-component-model-experiments/ npm run test:e2e:all:preview + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: ./packages/web-host/playwright-report/ + retention-days: 30 - name: Cache build artifacts id: cache-build-www-host uses: actions/cache@v4 diff --git a/README.md b/README.md index 9e1dc2c..6bc1201 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,10 @@ rustup target add wasm32-unknown-unknown wasm32-wasip1 ``` ```bash +# Install project dependencies (web part) npm install +# Install Playwright browsers (e2e tests for web-host) +npx playwright install ``` ### pluginlab (rust) diff --git a/packages/web-host/playwright.config.ts b/packages/web-host/playwright.config.ts index 3d31df0..dfb0f1f 100644 --- a/packages/web-host/playwright.config.ts +++ b/packages/web-host/playwright.config.ts @@ -28,11 +28,20 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: process.env.BASE_URL || - "http://localhost:5173/webassembly-component-model-experiments", + "http://localhost:5173/webassembly-component-model-experiments/", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", }, + ...(process.env.WAIT_FOR_SERVER_AT_URL + ? { + webServer: { + command: "npm run preview -- --strictPort", + url: process.env.WAIT_FOR_SERVER_AT_URL, + reuseExistingServer: !process.env.CI, + }, + } + : {}), /* Configure projects for major browsers */ projects: [ From d398c6aa2474d6b99696ec7a85934ba867f6a8c3 Mon Sep 17 00:00:00 2001 From: Christophe Rosset Date: Sun, 13 Jul 2025 15:37:49 +0200 Subject: [PATCH 05/11] feat(web-host): change logic/message from Loading to Loaded plugins/repl-logic to represent the reality + e2e tests --- packages/web-host/src/components/ReplPage.tsx | 4 ++- packages/web-host/src/wasm/engine.ts | 33 +++++++++++-------- packages/web-host/tests/repl-loading.spec.ts | 21 ++++++++++++ 3 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 packages/web-host/tests/repl-loading.spec.ts diff --git a/packages/web-host/src/components/ReplPage.tsx b/packages/web-host/src/components/ReplPage.tsx index 3ee4105..66a0a38 100644 --- a/packages/web-host/src/components/ReplPage.tsx +++ b/packages/web-host/src/components/ReplPage.tsx @@ -26,7 +26,9 @@ export const ReplPage = ({ onBackToHome }: ReplPageProps) => { {wasm.status === "loading" &&
Loading...
} {wasm.status === "ready" && } - {wasm.status === "error" &&
Error
} + {wasm.status === "error" && ( +
An error occurred, please try again.
+ )} ); }; diff --git a/packages/web-host/src/wasm/engine.ts b/packages/web-host/src/wasm/engine.ts index ff5b585..aaa4907 100644 --- a/packages/web-host/src/wasm/engine.ts +++ b/packages/web-host/src/wasm/engine.ts @@ -40,25 +40,33 @@ async function loadPlugins({ import("./generated/plugin_greet/transpiled/plugin_greet.js"), import("./generated/plugin_ls/transpiled/plugin_ls.js"), import("./generated/plugin_cat/transpiled/plugin_cat.js"), - ]).then((plugins) => plugins.map((plugin) => plugin.plugin)); - - // log the plugins names - const pluginsNames = plugins.map((plugin) => plugin.name()); - for (const pluginName of pluginsNames) { - addReplHistoryEntry({ - stdin: `[Host] Loading plugin: ${pluginName}`, - }); - } + ]).then((plugins) => + plugins.map((plugin) => { + addReplHistoryEntry({ + stdin: `[Host] Loaded plugin: ${plugin.plugin.name()}`, + }); + return plugin.plugin; + }), + ); // set the plugins names in the host state + const pluginsNames = plugins.map((plugin) => plugin.name()); hostStateSetPluginsNames(pluginsNames); // return the plugins instances return plugins; } -async function loadReplLogicGuest(): Promise { - return import("./generated/repl_logic_guest/transpiled/repl_logic_guest.js"); +async function loadReplLogicGuest({ + addReplHistoryEntry, +}: AddReplHistoryEntryProp): Promise { + const replLogicGuest = await import( + "./generated/repl_logic_guest/transpiled/repl_logic_guest.js" + ); + addReplHistoryEntry({ + stdin: `[Host] Loaded REPL logic`, + }); + return replLogicGuest; } export async function prepareEngine({ @@ -75,9 +83,8 @@ export async function prepareEngine({ return; } addReplHistoryEntry({ stdin: `[Host] Starting REPL host...` }); - addReplHistoryEntry({ stdin: `[Host] Loading REPL logic` }); const [replLogicGuest, plugins] = await Promise.all([ - loadReplLogicGuest(), + loadReplLogicGuest({ addReplHistoryEntry }), loadPlugins({ addReplHistoryEntry }), ]); const engine = makeEngine(); diff --git a/packages/web-host/tests/repl-loading.spec.ts b/packages/web-host/tests/repl-loading.spec.ts new file mode 100644 index 0000000..4b3a01b --- /dev/null +++ b/packages/web-host/tests/repl-loading.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; + +test("wasm should load", async ({ page }) => { + await page.goto("/#repl"); + await expect(page.getByText("[Host] Starting REPL host...")).toBeVisible(); +}); + +test("repl logic should have loaded", async ({ page }) => { + await page.goto("/#repl"); + await expect(page.getByText("[Host] Loaded REPL logic")).toBeVisible(); +}); + +test("plugins should have loaded under their names", async ({ page }) => { + const pluginNames = ["echo", "weather", "greet", "ls", "cat"]; + await page.goto("/#repl"); + for (const pluginName of pluginNames) { + await expect( + page.getByText(`[Host] Loaded plugin: ${pluginName}`), + ).toBeVisible(); + } +}); From 1ed16f341225600deda2080253d8a41e7aefb6d7 Mon Sep 17 00:00:00 2001 From: Christophe Rosset Date: Sun, 13 Jul 2025 16:09:03 +0200 Subject: [PATCH 06/11] tests(web-host): e2e test echo foo --- packages/web-host/src/components/Repl.tsx | 1 + .../web-host/src/components/ReplHistory.tsx | 6 +++-- packages/web-host/tests/repl-plugins.spec.ts | 27 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 packages/web-host/tests/repl-plugins.spec.ts diff --git a/packages/web-host/src/components/Repl.tsx b/packages/web-host/src/components/Repl.tsx index 4ae287d..58a9dd6 100644 --- a/packages/web-host/src/components/Repl.tsx +++ b/packages/web-host/src/components/Repl.tsx @@ -92,6 +92,7 @@ export function Repl({ className="border border-gray-300 rounded-md p-2 w-full pr-10" onFocus={() => setInputFocus(true)} onBlur={() => setInputFocus(false)} + placeholder="Type a command..." />