From 9f010cda6729b01725c159910be51613744f1a5b Mon Sep 17 00:00:00 2001 From: xiong35 Date: Fri, 4 Sep 2026 14:22:59 +0800 Subject: [PATCH] feat(examples): add Midscene Test Runner demo --- README.md | 5 + midscene-test-runner-demo/.env.example | 5 + midscene-test-runner-demo/.gitignore | 5 + midscene-test-runner-demo/README.md | 151 ++++++ midscene-test-runner-demo/cases/todo.yaml | 62 +++ midscene-test-runner-demo/midscene-nodes.md | 508 +++++++++++++++++++ midscene-test-runner-demo/midscene.config.ts | 75 +++ midscene-test-runner-demo/nodes/todo.ts | 191 +++++++ midscene-test-runner-demo/package.json | 24 + midscene-test-runner-demo/tsconfig.json | 12 + 10 files changed, 1038 insertions(+) create mode 100644 midscene-test-runner-demo/.env.example create mode 100644 midscene-test-runner-demo/.gitignore create mode 100644 midscene-test-runner-demo/README.md create mode 100644 midscene-test-runner-demo/cases/todo.yaml create mode 100644 midscene-test-runner-demo/midscene-nodes.md create mode 100644 midscene-test-runner-demo/midscene.config.ts create mode 100644 midscene-test-runner-demo/nodes/todo.ts create mode 100644 midscene-test-runner-demo/package.json create mode 100644 midscene-test-runner-demo/tsconfig.json diff --git a/README.md b/README.md index a5a4225..2518fa4 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,17 @@ Examples for [Midscene.js](https://github.com/web-infra-dev/midscene). Here are some examples you can refer to: ### Web Browser + - [Automate with Scripts in YAML](./yaml-scripts-demo/): Automate with scripts in YAML. This is the easiest way to integrate Midscene with your existing project. - [Integrate with Playwright](./playwright-demo/): Integrate Midscene with Playwright, including ai action, query ,cache, and report. - [Integrate with Puppeteer](./puppeteer-demo/): Integrate Midscene with Puppeteer, including ai action, query, cache, and report. - [Integrate with Puppeteer and Vitest](./puppeteer-with-vitest-demo/): Integrate Midscene with Puppeteer and Vitest. This is an alternative way to do test without Playwright. - [Bridge Mode](./bridge-mode-demo/): Bridge Mode allow you to use Midscene with your Desktop Chrome. +### Midscene Test Runner + +- [Web Test Runner Demo](./midscene-test-runner-demo/): Build an extensible `@midscene/test` Test Project with custom Nodes, `describe-nodes`, YAML cases, lifecycle hooks, Playwright setup, and reports. + ### Android - [JavaScript SDK Demo](./android/javascript-sdk-demo/): Integrate Midscene with Android, including ai action, query, cache, and report. - [Vitest Demo](./android/vitest-demo/): Integrate Midscene with Android and Vitest. diff --git a/midscene-test-runner-demo/.env.example b/midscene-test-runner-demo/.env.example new file mode 100644 index 0000000..be462ae --- /dev/null +++ b/midscene-test-runner-demo/.env.example @@ -0,0 +1,5 @@ +# Replace these placeholders with a model supported by Midscene. +MIDSCENE_MODEL_BASE_URL="YOUR_MODEL_BASE_URL" +MIDSCENE_MODEL_API_KEY="YOUR_MODEL_API_KEY" +MIDSCENE_MODEL_NAME="YOUR_MODEL_NAME" +MIDSCENE_MODEL_FAMILY="YOUR_MODEL_FAMILY" diff --git a/midscene-test-runner-demo/.gitignore b/midscene-test-runner-demo/.gitignore new file mode 100644 index 0000000..d7a5713 --- /dev/null +++ b/midscene-test-runner-demo/.gitignore @@ -0,0 +1,5 @@ +.env +.midscene/ +log/ +midscene_run/ +node_modules/ diff --git a/midscene-test-runner-demo/README.md b/midscene-test-runner-demo/README.md new file mode 100644 index 0000000..0f66bc1 --- /dev/null +++ b/midscene-test-runner-demo/README.md @@ -0,0 +1,151 @@ +# Midscene Test Runner Demo + +This example uses the [`@midscene/test`](https://midscenejs.com/test-runner-overview.html) Test Runner to execute Web test cases against [TodoMVC React](https://todomvc.com/examples/react/dist/). + +It demonstrates the boundary between the two authoring layers: + +- TypeScript Nodes provide reusable, validated capabilities and own the Playwright selectors. +- YAML cases describe business intent by composing deterministic and AI Nodes. + +The deterministic Nodes prepare data and verify exact state. `aiAct` and `aiAssert` handle visual, semantic interaction. This keeps the case readable without making its final result ambiguous. + +## How this example differs from nearby demos + +| Example | Runner | Case format | Main purpose | +| --- | --- | --- | --- | +| [`playwright-testing-demo`](../playwright-testing-demo/) | Playwright Test | TypeScript spec | Use Midscene fixtures inside Playwright Test | +| [`playwright-with-vitest-demo`](../playwright-with-vitest-demo/) | Vitest | TypeScript test | Organize Midscene Web tests with Vitest | +| `midscene-test-runner-demo` | `@midscene/test` | YAML cases + TypeScript Nodes | Define a Test Project, extend Nodes, use lifecycle hooks, and inspect Nodes with `describe-nodes` | + +This is not the legacy `@midscene/cli` YAML format shown by [`yaml-scripts-demo`](../yaml-scripts-demo/). + +## Project structure + +```text +midscene-test-runner-demo/ +├── cases/ +│ └── todo.yaml # Workflow lifecycle and test cases +├── nodes/ +│ └── todo.ts # Reusable, deterministic TodoMVC Nodes +├── .env.example # Model configuration template +├── midscene-nodes.md # Generated Node contract; do not edit +├── midscene.config.ts # Test Project, Playwright, and Agent setup +├── package.json +└── tsconfig.json +``` + +The example registers three custom Nodes: + +- `todo.seed`: resets TodoMVC and creates items through the visible UI. +- `todo.expectState`: requires the exact ordered titles and completed states. +- `todo.captureState`: records the current DOM state from `afterEach` for diagnostics. + +It also registers the Midscene Nodes (`aiAct`, `aiAssert`, `recordToReport`, and others) and the Playwright preset Nodes (`gotoUrl`, `setViewportSize`, and others). + +## Quick start + +This repository contains independent examples. Run the commands inside this directory: + +```bash +cd midscene-test-runner-demo +pnpm install +pnpm run browser:install +``` + +First, inspect every Node that this Test Project exposes: + +```bash +pnpm run describe:nodes +``` + +`describe-nodes` only loads `midscene.config.ts`. It does not launch Chromium, open TodoMVC, or require an API Key. + +The complete generated reference is committed as [`midscene-nodes.md`](./midscene-nodes.md) so readers and tooling can inspect the Test Project contract without installing the example. **Never edit this file manually.** After changing Nodes, configuration, or the pinned Midscene packages, regenerate it with: + +```bash +pnpm run describe:nodes:file +``` + +The output for a custom Node includes its description and a JSON Schema generated from Zod, for example: + +```md +## `todo.seed` + +**Title:** Seed the Todo list + +Reset TodoMVC and create an ordered list of Todos through the visible input. +``` + +Next, copy the model template and fill in a valid Midscene model configuration: + +```bash +cp .env.example .env +``` + +```env +MIDSCENE_MODEL_BASE_URL="YOUR_MODEL_BASE_URL" +MIDSCENE_MODEL_API_KEY="YOUR_MODEL_API_KEY" +MIDSCENE_MODEL_NAME="YOUR_MODEL_NAME" +MIDSCENE_MODEL_FAMILY="YOUR_MODEL_FAMILY" +``` + +See the [model strategy documentation](https://midscenejs.com/model-strategy.html) for supported models and the exact variables required by your provider. + +Run the cases in a visible browser: + +```bash +pnpm run test:headed +``` + +Use `pnpm test` for headless execution. + +## What the cases do + +[`cases/todo.yaml`](./cases/todo.yaml) contains one Workflow Document with two cases: + +1. **Seed TodoMVC with deterministic Nodes** verifies that lifecycle setup and the custom Todo Nodes work without asking AI to prepare fixture data. +2. **Manage TodoMVC with AI** asks AI to delete one Todo and complete another, validates the visible meaning with `aiAssert`, and then verifies the exact DOM state with `todo.expectState`. + +The lifecycle is: + +```text +Project setup: launch Chromium + beforeAll: set the viewport + Case 1: beforeEach -> steps -> afterEach + Case 2: beforeEach -> steps -> afterEach + afterAll: add a note to the Midscene report +Project teardown: destroy Agent -> close Chromium +``` + +`beforeEach` navigates to TodoMVC and calls `todo.seed`, so every case attempt starts with the same three items. `afterEach` calls `todo.captureState` even when a case body fails. + +## Where to find the results + +After a run, inspect: + +- `.midscene/test-results//summary.json` for the Test Runner summary. +- `midscene_run/report/` for the Midscene HTML report and AI execution details. + +Both locations are ignored by Git. + +## Try small changes + +These edits make the runner behavior easy to observe: + +1. Add an item to `todo.seed.items` and update the expected state in both cases. +2. Add an unknown field under `todo.seed` to see `z.strictObject()` reject the input before Node execution. +3. Rename `todo.expectState` in the YAML to see collection fail for an unknown Node. +4. Change a Node description, run `pnpm run describe:nodes:file`, and inspect the generated diff in `midscene-nodes.md`. + +## Troubleshooting + +- **Model configuration error:** `describe:nodes` should still work. Check `.env` before running the AI cases. +- **Chromium executable missing:** run `pnpm run browser:install` in this directory. +- **TodoMVC navigation failure:** verify that `https://todomvc.com/examples/react/dist/` is reachable from your environment. +- **Need to watch the actions:** use `pnpm run test:headed`; `pnpm test` is headless by default. + +## References + +- [Test Runner overview](https://midscenejs.com/test-runner-overview.html) +- [Write and run Test Runner cases](https://midscenejs.com/use-test-runner.html) +- [Extend the Test Runner with custom Nodes](https://midscenejs.com/extend-test-runner.html) diff --git a/midscene-test-runner-demo/cases/todo.yaml b/midscene-test-runner-demo/cases/todo.yaml new file mode 100644 index 0000000..92addad --- /dev/null +++ b/midscene-test-runner-demo/cases/todo.yaml @@ -0,0 +1,62 @@ +beforeAll: + - setViewportSize: + width: 1280 + height: 720 + +beforeEach: + - gotoUrl: + url: ./ + - todo.seed: + items: + - Learn JS today + - Learn Rust tomorrow + - Learn AI the day after tomorrow + +cases: + - name: Seed TodoMVC with deterministic Nodes + tags: [deterministic] + steps: + - todo.expectState: + items: + - title: Learn JS today + completed: false + - title: Learn Rust tomorrow + completed: false + - title: Learn AI the day after tomorrow + completed: false + + - name: Manage TodoMVC with AI + tags: [smoke, ai] + steps: + - aiAct: + prompt: Delete the Todo named "Learn Rust tomorrow". + $: + timeout: 60000 + - aiAct: + prompt: Mark the Todo named "Learn AI the day after tomorrow" as completed. + $: + timeout: 60000 + - aiAssert: + prompt: >- + Exactly two Todos remain. "Learn AI the day after tomorrow" is + completed, "Learn JS today" is not completed, and the footer says + "1 item left". + message: TodoMVC does not show the expected final state. + $: + timeout: 60000 + - todo.expectState: + items: + - title: Learn JS today + completed: false + - title: Learn AI the day after tomorrow + completed: true + +afterEach: + - todo.captureState: {} + +afterAll: + - recordToReport: + title: TodoMVC Test Runner demo completed + content: >- + This workflow combines reusable deterministic Nodes with Midscene AI + actions and assertions. diff --git a/midscene-test-runner-demo/midscene-nodes.md b/midscene-test-runner-demo/midscene-nodes.md new file mode 100644 index 0000000..80ee303 --- /dev/null +++ b/midscene-test-runner-demo/midscene-nodes.md @@ -0,0 +1,508 @@ + + +# Midscene Test Node Reference + +This document describes the nodes registered by the current Test Project. + +## Workflow Step Contract + +Each step uses a node name as its key. The `$` object contains engine metadata and is not part of the node input. + +```yaml +workflow: + - node.name: + prompt: Describe the task. + $: + timeout: 30000 + continue-on-error: false +``` + +A string step value is shorthand for `{ prompt: value }`. Whether that input is valid depends on the node input schema. + +## `agent` + +Execute one self-contained natural-language task with an injected Agent executor. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "prompt": { + "pattern": "\\S", + "type": "string" + } + }, + "required": [ + "prompt" + ], + "type": "object" +} +``` + +## `aiAct` + +Perform a natural-language task with a Midscene UI Agent. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "convertHttpImage2Base64": { + "type": "boolean" + }, + "images": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "\\S", + "type": "string" + }, + "url": { + "pattern": "\\S", + "type": "string" + } + }, + "required": [ + "name", + "url" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "options": { + "additionalProperties": false, + "properties": { + "cacheable": { + "type": "boolean" + }, + "context": { + "type": "string" + }, + "deepLocate": { + "type": "boolean" + }, + "deepThink": { + "anyOf": [ + { + "const": "unset", + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "fileChooserAccept": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + } + }, + "type": "object" + }, + "prompt": { + "pattern": "\\S", + "type": "string" + } + }, + "required": [ + "prompt" + ], + "type": "object" +} +``` + +## `aiAssert` + +Assert a natural-language condition with a Midscene UI Agent. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "convertHttpImage2Base64": { + "type": "boolean" + }, + "images": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "\\S", + "type": "string" + }, + "url": { + "pattern": "\\S", + "type": "string" + } + }, + "required": [ + "name", + "url" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "message": { + "type": "string" + }, + "options": { + "additionalProperties": false, + "properties": { + "context": { + "type": "string" + }, + "domIncluded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "const": "visible-only", + "type": "string" + } + ] + }, + "screenshotIncluded": { + "type": "boolean" + } + }, + "type": "object" + }, + "prompt": { + "pattern": "\\S", + "type": "string" + } + }, + "required": [ + "prompt" + ], + "type": "object" +} +``` + +## `clearCookies` + +**Title:** Clear browser cookies + +Clear all cookies from the current Playwright BrowserContext, or only cookies matching name, domain, or path. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "domain": { + "pattern": "\\S", + "type": "string" + }, + "name": { + "pattern": "\\S", + "type": "string" + }, + "path": { + "pattern": "\\S", + "type": "string" + } + }, + "type": "object" +} +``` + +## `gotoUrl` + +**Title:** Open a Web URL + +Navigate the current Playwright Page to an absolute HTTP(S) URL or a path relative to the configured baseUrl. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "prompt": { + "minLength": 1, + "type": "string" + }, + "timeoutMs": { + "default": 60000, + "exclusiveMinimum": 0, + "type": "number" + }, + "url": { + "minLength": 1, + "type": "string" + }, + "waitUntil": { + "default": "domcontentloaded", + "enum": [ + "commit", + "domcontentloaded", + "load", + "networkidle" + ], + "type": "string" + } + }, + "type": "object" +} +``` + +## `recordToReport` + +Add text or screenshots to the current Midscene report. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "screenshotBase64": { + "type": "string" + }, + "screenshots": { + "items": { + "additionalProperties": false, + "properties": { + "base64": { + "minLength": 1, + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "base64" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "type": "object" +} +``` + +## `setCookies` + +**Title:** Set browser cookies + +Load cookies from an environment variable, configured profile, or Playwright storage-state file without persisting cookie values in workflow input or output. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "cookiesEnv": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "profile": { + "pattern": "\\S", + "type": "string" + }, + "storageStatePath": { + "pattern": "\\S", + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "type": "object" +} +``` + +## `setViewportSize` + +**Title:** Set the browser viewport size + +Set the current Playwright Page viewport size in CSS pixels and return the effective size. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "height": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "width": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + } + }, + "required": [ + "width", + "height" + ], + "type": "object" +} +``` + +## `todo.captureState` + +**Title:** Capture the current Todo state + +Read the current TodoMVC DOM without asserting it, for lifecycle diagnostics and history. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + +## `todo.expectState` + +**Title:** Expect the exact Todo state + +Read TodoMVC from the DOM and require the ordered titles and completed states to match exactly. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "items": { + "items": { + "additionalProperties": false, + "properties": { + "completed": { + "type": "boolean" + }, + "title": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "title", + "completed" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" +} +``` + +## `todo.seed` + +**Title:** Seed the Todo list + +Reset TodoMVC and create an ordered list of Todos through the visible input. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "items": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" +} +``` + +## `wait` + +Wait for a fixed duration while honoring cancellation. + +### Input Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "duration": { + "exclusiveMinimum": 0, + "type": "number" + }, + "unit": { + "default": "ms", + "enum": [ + "ms", + "s", + "min" + ], + "type": "string" + } + }, + "required": [ + "duration" + ], + "type": "object" +} +``` diff --git a/midscene-test-runner-demo/midscene.config.ts b/midscene-test-runner-demo/midscene.config.ts new file mode 100644 index 0000000..8cfba7b --- /dev/null +++ b/midscene-test-runner-demo/midscene.config.ts @@ -0,0 +1,75 @@ +import "dotenv/config"; + +import { defineProjectSetup, defineTestProject } from "@midscene/test/config"; +import { createMidsceneNodes } from "@midscene/test/midscene"; +import { createPlaywrightNodes } from "@midscene/test/playwright"; +import { PlaywrightAgent } from "@midscene/web/playwright"; +import { type Browser, type Page, chromium } from "playwright"; +import { createTodoNodes } from "./nodes/todo"; + +const TODO_MVC_URL = "https://todomvc.com/examples/react/dist/"; + +interface ProjectContext { + browser: Browser; + page: Page; + baseUrl: string; + agent?: PlaywrightAgent; +} + +const playwrightSetup = defineProjectSetup({ + name: "playwright", + platform: "web", + async setup({ onTeardown }) { + const browser = await chromium.launch({ + headless: process.env.HEADLESS !== "false", + }); + onTeardown(() => browser.close()); + + const page = await browser.newPage(); + const context: ProjectContext = { + browser, + page, + baseUrl: TODO_MVC_URL, + }; + + // Project teardown runs in LIFO order, so the Agent is destroyed before + // Playwright closes the browser registered above. + onTeardown(async () => context.agent?.destroy()); + return context; + }, +}); + +const playwrightNodes = createPlaywrightNodes({ + getPage: ({ context }) => context.page, + getBaseUrl: ({ context }) => context.baseUrl, +}); + +const midsceneNodes = createMidsceneNodes({ + getAgent: ({ context }) => { + context.agent ??= new PlaywrightAgent(context.page); + return context.agent; + }, + includeLaunch: false, +}); + +const todoNodes = createTodoNodes({ + getPage: (context) => context.page, +}); + +export default defineTestProject({ + projects: [ + { + name: "chromium", + platform: "web", + setup: playwrightSetup, + files: { include: ["cases/**/*.{yaml,yml}"] }, + }, + ], + nodes: [...todoNodes, ...playwrightNodes, ...midsceneNodes], + test: { + testTimeout: 120_000, + }, + output: { + reportDir: "./midscene_run/report", + }, +}); diff --git a/midscene-test-runner-demo/nodes/todo.ts b/midscene-test-runner-demo/nodes/todo.ts new file mode 100644 index 0000000..421b25d --- /dev/null +++ b/midscene-test-runner-demo/nodes/todo.ts @@ -0,0 +1,191 @@ +import { defineNode, type NodeDefinition, z } from "@midscene/test"; +import type { Page } from "playwright"; + +const NEW_TODO_SELECTOR = ".new-todo"; +const TODO_ITEM_SELECTOR = ".todo-list li"; +const TODO_LABEL_SELECTOR = "label"; + +const todoItemSchema = z.strictObject({ + title: z.string().trim().min(1).describe("Expected Todo title."), + completed: z + .boolean() + .describe("Whether the Todo should be in the completed state."), +}); + +const seedInputSchema = z.strictObject({ + items: z + .array(z.string().trim().min(1)) + .min(1) + .describe("Todo titles to create, in order."), +}); + +const expectStateInputSchema = z.strictObject({ + items: z + .array(todoItemSchema) + .describe("The exact ordered Todo list expected on the page."), +}); + +const emptyInputSchema = z.strictObject({}); + +export interface TodoItemState { + title: string; + completed: boolean; +} + +export interface TodoState { + items: TodoItemState[]; + total: number; + completed: number; + remaining: number; +} + +export interface CreateTodoNodesOptions { + getPage(context: TContext): Page | Promise; +} + +const readTodoState = async (page: Page): Promise => { + const itemLocators = page.locator(TODO_ITEM_SELECTOR); + const total = await itemLocators.count(); + const items: TodoItemState[] = []; + + for (let index = 0; index < total; index += 1) { + const item = itemLocators.nth(index); + const title = ( + await item.locator(TODO_LABEL_SELECTOR).textContent() + )?.trim(); + if (!title) { + throw new Error( + `Todo item ${index + 1} has no readable label at ${page.url()}.`, + ); + } + items.push({ + title, + completed: await item.evaluate((element) => + element.classList.contains("completed"), + ), + }); + } + + const completed = items.filter((item) => item.completed).length; + return { + items, + total, + completed, + remaining: total - completed, + }; +}; + +const assertExactItems = ( + page: Page, + expected: readonly TodoItemState[], + actual: readonly TodoItemState[], +): void => { + if (JSON.stringify(actual) === JSON.stringify(expected)) return; + + throw new Error( + [ + `Todo state does not match at ${page.url()}.`, + `Expected: ${JSON.stringify(expected, null, 2)}`, + `Actual: ${JSON.stringify(actual, null, 2)}`, + ].join("\n"), + ); +}; + +export function createTodoNodes( + options: CreateTodoNodesOptions, +): readonly NodeDefinition[] { + const seedTodos = defineNode({ + name: "todo.seed", + title: "Seed the Todo list", + description: + "Reset TodoMVC and create an ordered list of Todos through the visible input.", + inputSchema: seedInputSchema, + async execute({ input, context, signal }) { + const page = await options.getPage(context); + const currentUrl = page.url(); + if ( + !currentUrl.startsWith("http://") && + !currentUrl.startsWith("https://") + ) { + throw new Error( + `todo.seed requires an HTTP(S) page, but the current URL is ${currentUrl}. Run gotoUrl first.`, + ); + } + + signal.throwIfAborted(); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: "domcontentloaded" }); + + const inputBox = page.locator(NEW_TODO_SELECTOR); + await inputBox.waitFor({ state: "visible", timeout: 10_000 }); + for (const title of input.items) { + signal.throwIfAborted(); + await inputBox.fill(title); + await inputBox.press("Enter"); + } + + await page + .locator(TODO_ITEM_SELECTOR) + .nth(input.items.length - 1) + .waitFor({ state: "visible", timeout: 10_000 }); + + const state = await readTodoState(page); + const expected = input.items.map((title) => ({ + title, + completed: false, + })); + assertExactItems(page, expected, state.items); + + return { + summary: `Created ${state.total} Todos through the TodoMVC UI.`, + data: state, + }; + }, + }); + + const expectTodoState = defineNode< + typeof expectStateInputSchema, + TodoState, + TContext + >({ + name: "todo.expectState", + title: "Expect the exact Todo state", + description: + "Read TodoMVC from the DOM and require the ordered titles and completed states to match exactly.", + inputSchema: expectStateInputSchema, + async execute({ input, context, signal }) { + signal.throwIfAborted(); + const page = await options.getPage(context); + const state = await readTodoState(page); + assertExactItems(page, input.items, state.items); + + return { + summary: `Verified ${state.total} Todos: ${state.completed} completed and ${state.remaining} remaining.`, + data: state, + }; + }, + }); + + const captureTodoState = defineNode< + typeof emptyInputSchema, + TodoState, + TContext + >({ + name: "todo.captureState", + title: "Capture the current Todo state", + description: + "Read the current TodoMVC DOM without asserting it, for lifecycle diagnostics and history.", + inputSchema: emptyInputSchema, + async execute({ context, signal }) { + signal.throwIfAborted(); + const page = await options.getPage(context); + const state = await readTodoState(page); + return { + summary: `Captured ${state.total} Todos: ${state.completed} completed and ${state.remaining} remaining.`, + data: state, + }; + }, + }); + + return [seedTodos, expectTodoState, captureTodoState]; +} diff --git a/midscene-test-runner-demo/package.json b/midscene-test-runner-demo/package.json new file mode 100644 index 0000000..11414d0 --- /dev/null +++ b/midscene-test-runner-demo/package.json @@ -0,0 +1,24 @@ +{ + "name": "midscene-test-runner-demo", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "browser:install": "playwright install chromium", + "describe:nodes": "midscene-test describe-nodes", + "describe:nodes:file": "midscene-test describe-nodes > midscene-nodes.md", + "test": "midscene-test", + "test:headed": "cross-env HEADLESS=false midscene-test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@midscene/test": "1.12.3", + "@midscene/web": "1.12.3", + "@playwright/test": "^1.56.1", + "@types/node": "~22.7.9", + "cross-env": "7.0.3", + "dotenv": "16.4.5", + "playwright": "^1.56.1", + "typescript": "~5.6.3" + } +} diff --git a/midscene-test-runner-demo/tsconfig.json b/midscene-test-runner-demo/tsconfig.json new file mode 100644 index 0000000..04b4bf9 --- /dev/null +++ b/midscene-test-runner-demo/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["midscene.config.ts", "nodes/**/*.ts"] +}