From 9adf5afca777b0c2dab3524aaa39628ce820c491 Mon Sep 17 00:00:00 2001 From: Exelo Date: Fri, 30 Oct 2026 12:07:21 +0100 Subject: [PATCH 1/2] test: add coverage and improve tests --- .devcontainer/devcontainer.json | 33 ++ .env.example | 98 +++- .github/CONTRIBUTING.md | 4 +- .github/actions/playwright-install/action.yml | 34 ++ .github/actions/prepare/action.yml | 10 + .github/workflows/e2e.yml | 59 +- .github/workflows/release.yml | 16 + .github/workflows/tests.yml | 58 +- .gitignore | 3 + .prettierignore | 1 + .vscode/launch.json | 44 ++ CLAUDE.md | 17 +- apps/editor/e2e/adoption.spec.ts | 7 +- apps/editor/e2e/appearance.spec.ts | 4 +- apps/editor/e2e/cache.spec.ts | 4 +- apps/editor/e2e/check-build.ts | 52 ++ apps/editor/e2e/code-editor.spec.ts | 10 + apps/editor/e2e/command-palette.spec.ts | 6 +- apps/editor/e2e/console.spec.ts | 10 +- apps/editor/e2e/ecs-live.spec.ts | 8 +- apps/editor/e2e/ecs.spec.ts | 4 +- apps/editor/e2e/git.spec.ts | 158 +++-- apps/editor/e2e/helpers.ts | 12 + apps/editor/e2e/keyboard.spec.ts | 5 +- apps/editor/e2e/manual-pass.spec.ts | 4 +- apps/editor/e2e/marketplace.spec.ts | 4 +- apps/editor/e2e/prepare-workspace.mjs | 20 +- apps/editor/e2e/project.spec.ts | 2 + apps/editor/e2e/runtime.spec.ts | 4 +- apps/editor/e2e/scene.spec.ts | 11 +- apps/editor/e2e/test.ts | 20 +- apps/editor/e2e/tips.spec.ts | 4 +- apps/editor/e2e/workbench.spec.ts | 10 +- apps/editor/package.json | 4 +- apps/editor/playwright.config.ts | 8 + apps/editor/scripts/last-failure.mjs | 56 ++ apps/editor/src/lib/editor/create-editor.ts | 2 + apps/editor/src/lib/editor/follow-saved.ts | 24 + apps/editor/src/lib/kit/KitPlayground.svelte | 152 +++++ apps/editor/src/lib/kit/KitSamples.svelte | 223 ++++++++ apps/editor/src/routes/kit/+page.svelte | 7 + apps/editor/src/routes/kit/+page.ts | 12 + .../test/adoption/adoption-service.spec.ts | 143 +++++ apps/editor/vite.config.ts | 20 +- commitlint.config.cjs | 30 +- coverage-floors.json | 170 ++++++ knip.json | 43 ++ package.json | 7 +- packages/code/package.json | 3 +- packages/history/package.json | 3 +- packages/kernel/package.json | 1 + packages/kernel/src/plugin/manifest-fields.ts | 4 +- packages/kernel/turbo.json | 10 + packages/layout/package.json | 1 + .../layout/src/serialization/layout.schema.ts | 4 +- packages/meta/package.json | 3 +- packages/project/package.json | 3 +- packages/project/src/cache/cache-db.ts | 4 +- .../src/workspace/library-operations.ts | 4 +- packages/project/vitest.config.ts | 4 + packages/protocol/package.json | 3 +- packages/registry/package.json | 1 + packages/rpc/package.json | 3 +- packages/runtime/package.json | 3 +- packages/sdk/package.json | 1 + packages/server-core/package.json | 7 +- packages/server-core/scripts/env-example.ts | 44 ++ .../scripts/generate-env-example.ts | 7 + packages/server-core/src/env/env.schema.ts | 14 +- packages/server-core/src/git/parse-status.ts | 2 +- .../server-core/src/http/compressed-files.ts | 2 +- packages/server-core/src/http/static-files.ts | 2 +- .../server-core/test/env/env-example.spec.ts | 11 + packages/server-core/turbo.json | 12 + packages/settings/package.json | 3 +- .../src/account-sync/account-sync-store.ts | 2 + .../settings/src/service/settings-service.ts | 52 +- .../account-sync/account-sync-store.spec.ts | 18 + .../test/service/settings-service.spec.ts | 18 + packages/ui/package.json | 3 +- .../src/workbench/layout/layout-controller.ts | 13 +- .../workbench/service/workbench-service.ts | 9 +- .../layout/layout-controller.spec.ts | 1 + packages/ui/vitest.config.ts | 4 + plugins/code-editor/package.json | 1 + .../code-editor/src/document/unsaved-store.ts | 2 +- .../src/monaco/dispose-diff-editor.ts | 18 + .../src/service/code-editor-service.ts | 4 +- .../code-editor/src/typing/type-libraries.ts | 4 +- .../src/widgets/script/EditorGroupView.svelte | 9 +- .../test/document/editor-document.spec.ts | 93 +++ .../test/document/unsaved-store.spec.ts | 16 + .../code-editor/test/fixtures/fake-monaco.ts | 62 ++ .../test/service/code-editor-service.spec.ts | 211 +++++++ .../test/typing/type-libraries.spec.ts | 119 +++- plugins/command-palette/package.json | 1 + .../src/palette/palette-model.ts | 2 +- plugins/console/package.json | 1 + .../console/src/problems/group-problems.ts | 2 +- plugins/console/src/store/console-store.ts | 2 +- plugins/ecs/package.json | 1 + plugins/ecs/src/create/create-item.ts | 6 +- .../extension/field-editor.extension-point.ts | 2 +- .../extension/param-preset.extension-point.ts | 2 +- plugins/ecs/src/layout/param-layout.ts | 10 +- plugins/ecs/src/live/live-service.ts | 12 +- plugins/ecs/src/model/arg-values.ts | 2 +- plugins/ecs/src/scene/build-scene.ts | 2 +- plugins/ecs/src/service/ecs-service.ts | 3 - plugins/ecs/src/worker/bundle-spawns.ts | 4 +- plugins/ecs/src/worker/component-args.ts | 5 +- plugins/ecs/src/worker/entry-file.ts | 4 +- plugins/ecs/src/worker/split-items.ts | 2 +- plugins/ecs/src/worker/transform-helpers.ts | 2 +- plugins/ecs/src/worker/transforms.ts | 4 +- plugins/ecs/test/create/create-item.spec.ts | 53 ++ plugins/ecs/test/model/arg-values.spec.ts | 65 +++ plugins/file-manager/package.json | 1 + .../src/service/file-manager-service.ts | 4 +- .../src/service/file-operations.ts | 8 +- .../test/filter/file-filter.spec.ts | 39 ++ plugins/file-manager/test/fixtures/service.ts | 78 +++ plugins/file-manager/test/fixtures/ui-mock.ts | 9 + .../file-manager/test/menu/file-menu.spec.ts | 97 ++++ .../test/service/file-manager-service.spec.ts | 94 +++ .../test/widgets/files/file-tree.spec.ts | 88 +++ plugins/git/package.json | 1 + plugins/history-panel/package.json | 5 +- .../widgets/history/relative-time.spec.ts | 17 + plugins/inspectors/package.json | 1 + plugins/packages/package.json | 1 + plugins/scene/package.json | 1 + .../extension/ecs-source.extension-point.ts | 2 +- plugins/scene/src/service/scene-service.ts | 6 +- plugins/scene/src/template/scene-template.ts | 2 +- .../scene/src/widgets/scenes/scene-tree.ts | 2 +- plugins/scene/src/worker/main-to-scene.ts | 2 +- plugins/scene/src/worker/scenes.ts | 6 +- plugins/scene/src/worker/vars.ts | 2 +- .../scene/test/service/scene-service.spec.ts | 196 +++++++ plugins/settings-ui/package.json | 1 + .../src/settings/setting-categories.ts | 5 +- .../test/marketplace/account-plugins.spec.ts | 103 ++++ .../test/settings/setting-categories.spec.ts | 51 ++ .../test/settings/setting-control.spec.ts | 60 ++ plugins/viewport/package.json | 5 +- plugins/viewport/src/game/resolutions.ts | 2 +- .../viewport/test/game/resolutions.spec.ts | 35 ++ pnpm-lock.yaml | 539 +++++++++++++++++- pnpm-workspace.yaml | 2 +- scripts/check-workspaces.mjs | 146 +++++ scripts/coverage-floors.mjs | 37 ++ scripts/engine-bridge.mjs | 19 +- scripts/pinned-refs.json | 10 + scripts/setup-check.mjs | 113 ++++ scripts/setup-engine.mjs | 52 ++ tooling/vite-plugin/package.json | 1 + turbo.json | 8 + vitest.config.ts | 9 +- vitest.coverage.ts | 25 + 160 files changed, 4208 insertions(+), 319 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/actions/playwright-install/action.yml create mode 100644 .vscode/launch.json create mode 100644 apps/editor/e2e/check-build.ts create mode 100644 apps/editor/scripts/last-failure.mjs create mode 100644 apps/editor/src/lib/editor/follow-saved.ts create mode 100644 apps/editor/src/lib/kit/KitPlayground.svelte create mode 100644 apps/editor/src/lib/kit/KitSamples.svelte create mode 100644 apps/editor/src/routes/kit/+page.svelte create mode 100644 apps/editor/src/routes/kit/+page.ts create mode 100644 apps/editor/test/adoption/adoption-service.spec.ts create mode 100644 coverage-floors.json create mode 100644 knip.json create mode 100644 packages/server-core/scripts/env-example.ts create mode 100644 packages/server-core/scripts/generate-env-example.ts create mode 100644 packages/server-core/test/env/env-example.spec.ts create mode 100644 packages/server-core/turbo.json create mode 100644 plugins/code-editor/src/monaco/dispose-diff-editor.ts create mode 100644 plugins/code-editor/test/document/editor-document.spec.ts create mode 100644 plugins/code-editor/test/document/unsaved-store.spec.ts create mode 100644 plugins/code-editor/test/fixtures/fake-monaco.ts create mode 100644 plugins/code-editor/test/service/code-editor-service.spec.ts create mode 100644 plugins/ecs/test/create/create-item.spec.ts create mode 100644 plugins/ecs/test/model/arg-values.spec.ts create mode 100644 plugins/file-manager/test/filter/file-filter.spec.ts create mode 100644 plugins/file-manager/test/fixtures/service.ts create mode 100644 plugins/file-manager/test/fixtures/ui-mock.ts create mode 100644 plugins/file-manager/test/menu/file-menu.spec.ts create mode 100644 plugins/file-manager/test/service/file-manager-service.spec.ts create mode 100644 plugins/file-manager/test/widgets/files/file-tree.spec.ts create mode 100644 plugins/history-panel/test/widgets/history/relative-time.spec.ts create mode 100644 plugins/scene/test/service/scene-service.spec.ts create mode 100644 plugins/settings-ui/test/marketplace/account-plugins.spec.ts create mode 100644 plugins/settings-ui/test/settings/setting-categories.spec.ts create mode 100644 plugins/settings-ui/test/settings/setting-control.spec.ts create mode 100644 plugins/viewport/test/game/resolutions.spec.ts create mode 100644 scripts/check-workspaces.mjs create mode 100644 scripts/coverage-floors.mjs create mode 100644 scripts/pinned-refs.json create mode 100644 scripts/setup-check.mjs create mode 100644 scripts/setup-engine.mjs create mode 100644 vitest.coverage.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..55b95896 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "NanoForge editor", + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "26" + }, + "ghcr.io/devcontainers-extra/features/bun:1": { + "version": "1.3.14" + } + }, + "postCreateCommand": "corepack enable && corepack prepare pnpm@12.0.0 --activate && pnpm install && pnpm --filter @nanoforge-dev/editor exec playwright install --with-deps chromium firefox && pnpm setup:check", + "forwardPorts": [5174, 4800], + "portsAttributes": { + "4800": { + "label": "Example (pnpm example)" + }, + "5174": { + "label": "Editor (pnpm dev)" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "svelte.svelte-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "vitest.explorer", + "ms-playwright.playwright" + ] + } + } +} diff --git a/.env.example b/.env.example index c1506f18..a489bec6 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,70 @@ -# production | development -NODE_ENV=development +# The editor server reads these variables (Bun also loads a .env file of the folder it +# starts in). Copy this file to .env and uncomment what you change. +# Generated from packages/server-core/src/env/env.schema.ts: run +# `pnpm --filter @nanoforge-dev/editor-server-core generate:env` after changing it. -# OFFLINE: local projects on disk (binds to 127.0.0.1). ONLINE: hosted, projects synced with git gateways. -PUBLIC_MODE=OFFLINE +# production, development (more logs, dev tooling) or test. +# NODE_ENV=production -# Server. In OFFLINE mode HOST must be a loopback address (no sign-in, project code runs), -# unless ALLOW_REMOTE_OFFLINE=1 and something else restricts who can connect. -# HOST=127.0.0.1 +# OFFLINE: local projects on disk. ONLINE: hosted, projects synced with git gateways. +# PUBLIC_MODE=OFFLINE + +# Address the server listens on. In OFFLINE mode it must be a loopback address (no sign-in, +# project code runs), unless ALLOW_REMOTE_OFFLINE is set. +# HOST= + +# Port the server listens on (0: any free port). +# PORT=3000 + +# Projects must live under this directory (defaults to the working directory). +# FS_ROOT= + +# Editor data: recent projects, installed plugins… (defaults to ~/.nanoforge/editor). +# DATA_DIR= + +# The NanoForge API. +# API_URL=https://api.nanoforge.eu + +# The editor's key for the NanoForge API: required in ONLINE mode. +# API_KEY= + +# The API features that are deployed, comma separated (settings,registry). None by default. +# API_FEATURES= + +# The registry of plugins and packages, when it is not the API's (registry feature). +# REGISTRY_URL= + +# A folder standing in for the registry (/@scope/name//): tests, offline work. +# REGISTRY_DIR= + +# NanoForge projects website, where ONLINE users sign in. +# PUBLIC_PM_URL=https://projects.nanoforge.eu + +# Signs the session cookies. Empty: a random one at each start (sessions end on restart). +# SESSION_SECRET= + +# The NanoForge CLI, when nf is not in the PATH (a project's own CLI is tried first). +# NF_CLI_PATH= + +# Git, when git is not in the PATH. +# GIT_PATH= + +# Extra origins allowed to call the server (dev proxies). +# ALLOWED_ORIGINS= + +# Host names the server answers to besides localhost (DNS rebinding guard), comma separated. +# A local editor reached by another name needs it; a hosted one checks it when it is set. +# ALLOWED_HOSTS= + +# Directories of local plugins under development (their dist/ is served). +# DEV_PLUGINS= + +# Directory of the plugins bundled with the editor build. +# BUNDLED_PLUGINS_DIR= + +# Files of the tutorial's project (_Help › Tutorial_), when not next to the editor build. +# TUTORIAL_DIR= + +# Lets a local (OFFLINE) editor listen on a non-loopback HOST. It has no sign-in and runs +# project code: only set it behind something that restricts who can connect. # ALLOW_REMOTE_OFFLINE= -PORT=3000 - -# Projects must live under this directory (default: current directory) -FS_ROOT= -# Editor data (recent projects, installed plugins). Default: ~/.nanoforge/editor -DATA_DIR= - -# NanoForge API (API_KEY required when PUBLIC_MODE=ONLINE) -API_URL=https://api.nanoforge.eu -API_KEY= -# Where ONLINE users sign in (the website sets the auth cookies) -PUBLIC_PM_URL=https://projects.nanoforge.eu - -# Leave empty to generate a random secret at start (sessions are lost on restart) -SESSION_SECRET= - -# Change if the binaries are not in the PATH (nf: the project's own node_modules/.bin is tried first) -NF_CLI_PATH= -GIT_PATH= - -# Comma separated list of local plugin folders under development (their dist/ is served, hot reloaded) -DEV_PLUGINS= -# Extra origins allowed to call the server (reverse proxies) -ALLOWED_ORIGINS= diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e6da5826..fc8fd9c0 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -7,8 +7,8 @@ If you wish to contribute to the NanoForge project, fork the repository and subm To get ready to work on the codebase, please do the following: 1. Fork & clone the repository, and make sure you're on the **main** branch -2. Run `pnpm install --frozen-lockfile` ([install](https://pnpm.io/installation)) -3. Copy `.env.example` to `.env` and modify env variable as you wish +2. Run `pnpm install --frozen-lockfile` ([install](https://pnpm.io/installation)), then `pnpm setup:check`: it says what else is missing (Bun, Chrome, a built engine for the tests that play games: `pnpm setup:engine`) +3. Optionally, copy `.env.example` to `.env`: every variable is listed there, commented out with its default; uncomment what you change 4. Run the editor `pnpm run dev` 5. Make your changes 6. Run `pnpm format && pnpm build && pnpm test` to run ESLint/Prettier, build and tests diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml new file mode 100644 index 00000000..7c2af663 --- /dev/null +++ b/.github/actions/playwright-install/action.yml @@ -0,0 +1,34 @@ +name: 'Install Playwright browsers' +description: 'Install Playwright browsers, cached by Playwright version' +inputs: + browsers: + description: 'Browsers to install, separated by spaces' + default: 'chromium' + filter: + description: 'Workspace whose Playwright installs them' + default: '@nanoforge-dev/editor' +runs: + using: 'composite' + steps: + - name: Read the Playwright version + id: version + shell: bash + run: echo "version=$(pnpm --filter '${{ inputs.filter }}' exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v5 + id: cache + name: Cache browsers + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.version.outputs.version }}-${{ inputs.browsers }} + + - name: Install browsers + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + run: pnpm --filter '${{ inputs.filter }}' exec playwright install --with-deps ${{ inputs.browsers }} + + # The browsers come from the cache; their system libraries do not. + - name: Install system libraries + if: steps.cache.outputs.cache-hit == 'true' + shell: bash + run: pnpm --filter '${{ inputs.filter }}' exec playwright install-deps ${{ inputs.browsers }} diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 96f90b1b..78728634 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -31,6 +31,16 @@ runs: name: Install dependencies uses: ./.github/actions/pnpm-install + # Turbo's local cache, shared by the jobs of a run and kept between runs of a branch. + - name: Cache turbo + uses: actions/cache@v5 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ github.ref_name }}- + ${{ runner.os }}-turbo- + - if: inputs.build == 'true' name: Build packages shell: bash diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 19cfa59e..1cef38f9 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -22,6 +22,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: e2e: runs-on: ubuntu-latest @@ -32,34 +36,83 @@ jobs: - name: Prepare uses: ./.github/actions/prepare + # The engine and CLI the tests play and build with: scripts/pinned-refs.json, also read + # by pnpm setup:engine. Move them on purpose, after a run against the new commits passes. + - name: Read the pinned engine and CLI + shell: bash + run: | + echo "ENGINE_REF=$(jq -r .engine.ref scripts/pinned-refs.json)" >> "$GITHUB_ENV" + echo "CLI_REF=$(jq -r .cli.ref scripts/pinned-refs.json)" >> "$GITHUB_ENV" + # The tests that play a game (runtime, inspectors, scene…) need a built engine with the - # editor library; without NANOFORGE_ENGINE they are skipped. + # editor library, and the tutorial a built CLI. E2E_REQUIRE_ENGINE fails the run when + # one is missing instead of skipping them. + - name: Cache engine and CLI + id: cache + uses: actions/cache@v5 + with: + path: | + engine + cli + key: ${{ runner.os }}-engine-${{ env.ENGINE_REF }}-cli-${{ env.CLI_REF }} + - name: Checkout engine + if: steps.cache.outputs.cache-hit != 'true' uses: actions/checkout@v7 with: repository: NanoForge-dev/Engine - ref: main + ref: ${{ env.ENGINE_REF }} path: engine - name: Install emsdk + if: steps.cache.outputs.cache-hit != 'true' uses: ./engine/.github/actions/emsdk-install - name: Install engine dependencies + if: steps.cache.outputs.cache-hit != 'true' uses: ./.github/actions/pnpm-install with: path: engine - name: Build engine + if: steps.cache.outputs.cache-hit != 'true' run: pnpm run build working-directory: engine + - name: Checkout CLI + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/checkout@v7 + with: + repository: NanoForge-dev/CLI + ref: ${{ env.CLI_REF }} + path: cli + + - name: Install CLI dependencies + if: steps.cache.outputs.cache-hit != 'true' + uses: ./.github/actions/pnpm-install + with: + path: cli + + - name: Build CLI + if: steps.cache.outputs.cache-hit != 'true' + run: | + pnpm --filter @nanoforge-dev/config build + pnpm --filter @nanoforge-dev/schematics build + pnpm build + working-directory: cli + - name: Install browsers - run: pnpm --filter @nanoforge-dev/editor exec playwright install --with-deps chromium firefox + uses: ./.github/actions/playwright-install + with: + browsers: firefox - name: Run end-to-end tests run: pnpm --filter @nanoforge-dev/editor test:e2e env: + CHROME: /usr/bin/google-chrome NANOFORGE_ENGINE: ${{ github.workspace }}/engine + NANOFORGE_CLI: ${{ github.workspace }}/cli + E2E_REQUIRE_ENGINE: '1' - name: Upload traces if: failure() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d2fa610..d6e0334e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,14 +55,30 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # A pull request that changes no code (docs, CI, issue forms) publishes no beta. + - name: Check for code changes + id: changes + shell: bash + run: | + if git diff --name-only "${{ github.event.pull_request.merge_commit_sha }}^" "${{ github.event.pull_request.merge_commit_sha }}" \ + | grep -qE '^(apps|packages|plugins|tooling)/|^(pnpm-lock|pnpm-workspace)\.yaml$|^turbo\.json$'; then + echo "code=true" >> "$GITHUB_OUTPUT" + else + echo "code=false" >> "$GITHUB_OUTPUT" + fi - name: Prepare + if: steps.changes.outputs.code == 'true' uses: ./.github/actions/prepare env: NODE_ENV: development PUBLIC_MODE: OFFLINE - name: Release beta + if: steps.changes.outputs.code == 'true' uses: ./node_modules/@nanoforge-dev/actions/actions/release-dev with: package: '@nanoforge-dev/editor' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 08697043..22218949 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: PUBLIC_MODE: ${{ vars.PUBLIC_MODE }} PUBLIC_PM_URL: ${{ vars.PUBLIC_PM_URL }} @@ -19,7 +23,8 @@ env: PUBLIC_LANDING_URL: ${{ vars.PUBLIC_LANDING_URL }} jobs: - tests: + lint: + name: Lint runs-on: ubuntu-latest steps: - name: Checkout repository @@ -28,16 +33,57 @@ jobs: - name: Prepare uses: ./.github/actions/prepare + # Prettier, the workspace standards (scripts/check-workspaces.mjs), knip and ESLint. - name: Run linter run: pnpm lint + typecheck: + name: Type checks + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Prepare + uses: ./.github/actions/prepare + - name: Run type checks run: pnpm typecheck - - name: Install browsers for component and cache tests - run: pnpm --filter @nanoforge-dev/editor-ui exec playwright install --with-deps chromium firefox + test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Prepare + uses: ./.github/actions/prepare + + - name: Install browsers + uses: ./.github/actions/playwright-install + with: + browsers: chromium firefox webkit - - name: Run tests - run: pnpm test:unit + # Coverage on Chromium (v8 coverage reads Chromium only), failing under each + # workspace's floor (coverage-floors.json). The kit's component tests use the runner's + # Chrome. + - name: Run tests with coverage + run: pnpm test:coverage env: - BROWSERS: chromium,firefox + CHROME: /usr/bin/google-chrome + + - name: Run the browser cache tests on Firefox and WebKit + run: pnpm --filter @nanoforge-dev/editor-project exec vitest run --config vitest.config.ts --project browser + env: + BROWSERS: firefox,webkit + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: | + */*/coverage/lcov.info + */*/coverage/coverage-summary.json + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 2953ca82..7b345ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -254,3 +254,6 @@ apps/editor/build/ examples/.data/ examples/node_modules .vitest-attachments + +# Engine and CLI built by pnpm setup:engine +.engine/ diff --git a/.prettierignore b/.prettierignore index bae5a534..176dbc42 100644 --- a/.prettierignore +++ b/.prettierignore @@ -24,3 +24,4 @@ tooling/ # Written by the editor in a project it opens (the example). **/.nanoforge/ +.engine/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..b9fa5e4b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,44 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Editor server (Bun, built)", + "type": "bun", + "request": "launch", + "program": "${workspaceFolder}/apps/editor/dist/index.js", + "cwd": "${workspaceFolder}/apps/editor", + "env": { "PORT": "4810", "FS_ROOT": "${workspaceFolder}/examples" }, + "stopOnEntry": false, + "watchMode": false + }, + { + "name": "Vitest: this test file", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", + "args": ["run", "--config", "${workspaceFolder}/vitest.config.ts", "${fileBasenameNoExtension}"], + "cwd": "${workspaceFolder}/${input:workspace}", + "env": { "CHROME": "/usr/bin/google-chrome-stable" }, + "console": "integratedTerminal", + "skipFiles": ["/**", "**/node_modules/**"] + }, + { + "name": "Playwright: this e2e spec", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/apps/editor/node_modules/@playwright/test/cli.js", + "args": ["test", "--config", "playwright.config.ts", "${fileBasename}", "--reporter=line"], + "cwd": "${workspaceFolder}/apps/editor", + "env": { "E2E_WORKSPACE": ".workspace-debug", "CHROME": "/usr/bin/google-chrome-stable" }, + "console": "integratedTerminal" + } + ], + "inputs": [ + { + "id": "workspace", + "type": "promptString", + "description": "Workspace of the test file (vitest runs from it)", + "default": "packages/kernel" + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index fa3d164b..f8b4df64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,9 +42,13 @@ The NanoForge editor, rewritten from scratch. The full plan, with status per pha ```sh pnpm install pnpm turbo build --filter=@nanoforge-dev/editor... # editor + every package and plugin it bundles -pnpm lint # prettier --check . + eslint in every package +pnpm setup:check # Node, pnpm, Bun, git, Chrome, a built engine and CLI: what is missing and how to get it +pnpm setup:engine # clones and builds the engine and CLI CI pins (scripts/pinned-refs.json) into .engine/ +pnpm lint # prettier --check . + workspace standards (scripts/check-workspaces.mjs) + knip + eslint in every package pnpm typecheck CHROME=/usr/bin/google-chrome-stable pnpm test # unit + Vitest browser tests (packages/ui needs CHROME) +CHROME=/usr/bin/google-chrome-stable pnpm test:coverage # the same with coverage, failing under coverage-floors.json +node scripts/coverage-floors.mjs [--write] # coverage per workspace; --write raises the floors pnpm dev # editor + built-in plugins in watch mode (hot reload) # E2E: against the production build. Build first, and rebuild after every change. @@ -53,9 +57,12 @@ CHROME=/usr/bin/google-chrome-stable \ NANOFORGE_ENGINE=/home/exelo/workspace/epitech/G6/G-EIP-600/engine \ NANOFORGE_CLI=/home/exelo/workspace/epitech/G6/G-EIP-600/cli \ npx playwright test [spec…] --reporter=line +pnpm e2e:last-failure # error-context.md of the last failed test, its last frame in test-results/last-failure.jpeg ``` - **Run e2e with `E2E_WORKSPACE=.workspace-agent`** (`.workspace` or `.workspace-`: anything else is refused, since the folder is wiped). The default `e2e/.workspace` is wiped at each run, and the user's dev server (`FS_ROOT=apps/editor`) may have its projects open. Specs take their paths from `e2e/workspace.ts`. +- The e2e run refuses a build older than the code (`e2e/check-build.ts`, names the newer file); `E2E_ALLOW_STALE=1` runs the old build anyway. +- `E2E_REQUIRE_ENGINE=1` (CI) fails the run when the `game`, `my-first-game` or `breakout` copy is missing, instead of skipping their tests. Without `NANOFORGE_ENGINE`/`NANOFORGE_CLI`, the e2e prep and `pnpm example` use `.engine/` when `pnpm setup:engine` made it, else `../engine`. - Without `NANOFORGE_ENGINE`, the tests that play the game (runtime, the viewport's "while playing", some code-editor tests) are **skipped**, not failed. Check the skipped count. - `NANOFORGE_CLI` (a built CLI checkout) is linked into the played game as its own CLI. Without it, the console test skips its source-map check (the game's published CLI writes no source maps). - Contract tests (`test/contract` in `server-core` and `registry`) run in `pnpm test` against the stand-ins, and against a real API with `CONTRACT_*` variables (`docs/api/README.md`). Never run them against a real API yourself. @@ -63,11 +70,13 @@ npx playwright test [spec…] --reporter=line - `E2E_EXTRA_PLUGINS=` adds a dev plugin to the e2e editor. - Unit tests fail on an unexpected `console.error` (`vitest.setup.ts`): a test that expects one spies on it (`vi.spyOn(console, 'error').mockImplementation(() => {})`). - SDK public API snapshot: after adding SDK exports, run `cd packages/sdk && npx vitest run -u`, and check that the diff shows only what you added. -- E2E specs import `test` and `expect` from `./test` (`e2e/test.ts`), not from `@playwright/test`: its fixture fails a test when a page throws an uncaught error. Known noise goes in its `IGNORED` list. +- E2E specs import `test` and `expect` from `./test` (`e2e/test.ts`), not from `@playwright/test`: its fixture fails a test when a page throws an uncaught error (known noise goes in its `IGNORED` list), and another stops the game a test leaves playing. +- **Never sleep to wait for a save:** `await saved(page)` (`e2e/helpers.ts`) waits for `data-nf-saved` on the page, set while no layout, panel state, settings write or account push is pending (`SettingsService.pending`; a debounce holds a pending save with `settings.hold()`). +- `/kit` (dev server only, 404 in a build) shows every kit component in both themes, and the floating layers' order over a dialog. - E2E specs open projects with `openProject(page, path)` (`e2e/helpers.ts`): `/load?path=` asks before opening a folder that is not an exact recent path (a relative path always asks), and the helper answers **Trust and open**. Server and runtime unit tests open projects with `openTrustedProject` (`@nanoforge-dev/editor-server-core/testing`): a fresh data folder trusts nothing. - The editor server answers only to loopback host names and `ALLOWED_HOSTS` (DNS rebinding guard): a dev server reached by another name needs it. -- To debug a failed e2e test, read `test-results//error-context.md`. For a screenshot, unzip `trace.zip` and open the last frame in `screencast/`. -- The CLI repo needs `pnpm --filter @nanoforge-dev/config build` before `pnpm build`. +- To debug a failed e2e test, `pnpm e2e:last-failure` (or read `test-results//error-context.md`, and the last frame of `trace.zip`). A minified Monaco stack: rebuild code-editor with `npx vite build --sourcemap --outDir ` (same file hashes) and map the frames with `@jridgewell/trace-mapping`. +- The CLI repo needs `pnpm --filter @nanoforge-dev/config build` before `pnpm build`, and `pnpm --filter @nanoforge-dev/schematics build` for `nf new` (its root build leaves the schematics out). ## Architecture in one screen diff --git a/apps/editor/e2e/adoption.spec.ts b/apps/editor/e2e/adoption.spec.ts index c9ce5a26..58b9a1a2 100644 --- a/apps/editor/e2e/adoption.spec.ts +++ b/apps/editor/e2e/adoption.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject, resetLayout } from './helpers'; +import { openProject, resetLayout, saved } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -26,8 +26,7 @@ test.beforeEach(() => { test.afterEach(async ({ page }) => { for (const path of snapshot().keys()) if (!original.has(path)) rmSync(path); for (const [path, bytes] of original) writeFileSync(path, Buffer.from(bytes, 'base64')); - /** The saved layout outlives the test. */ - await page.waitForTimeout(600); + await saved(page); }); const report = (page: Page) => page.getByRole('region', { name: 'Adapt to the editor' }); @@ -87,7 +86,7 @@ test('a suggestion is dismissed for the project', async ({ page }) => { await expect(install).toBeVisible({ timeout: 30_000 }); await install.getByRole('button', { name: 'Dismiss' }).click(); await expect(install).toHaveCount(0); - await page.waitForTimeout(900); + await saved(page); await page.reload(); await page.getByRole('tab', { name: 'Project', exact: true }).click(); await expand(page); diff --git a/apps/editor/e2e/appearance.spec.ts b/apps/editor/e2e/appearance.spec.ts index 7a1434f3..d2dbcae4 100644 --- a/apps/editor/e2e/appearance.spec.ts +++ b/apps/editor/e2e/appearance.spec.ts @@ -1,4 +1,4 @@ -import { openProject, resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; const openSettings = async (page: Page) => { @@ -27,7 +27,7 @@ const restore = async (page: Page) => { await page.getByRole('option', { name: 'NanoForge', exact: true }).click(); await dialog.getByRole('button', { name: 'OK' }).click(); await expect(dialog).toBeHidden(); - await page.waitForTimeout(900); + await saved(page); }; test('themes, density and icons from a plugin, chosen in Settings › Appearance', async ({ diff --git a/apps/editor/e2e/cache.spec.ts b/apps/editor/e2e/cache.spec.ts index 8df9ad84..dcc718ab 100644 --- a/apps/editor/e2e/cache.spec.ts +++ b/apps/editor/e2e/cache.spec.ts @@ -1,4 +1,4 @@ -import { expandFolders, openProject, resetLayout } from './helpers'; +import { expandFolders, openProject, resetLayout, saved } from './helpers'; import { type Page, expect, test } from './test'; /** Files kept in the browser's cache (OPFS), every user together. */ @@ -74,5 +74,5 @@ test('keeps project files in the browser and reads none of them again after a re await page.keyboard.press('Escape'); await page.getByRole('tab', { name: 'main.ts' }).click({ button: 'middle' }); await resetLayout(page); - await page.waitForTimeout(900); + await saved(page); }); diff --git a/apps/editor/e2e/check-build.ts b/apps/editor/e2e/check-build.ts new file mode 100644 index 00000000..df0d4241 --- /dev/null +++ b/apps/editor/e2e/check-build.ts @@ -0,0 +1,52 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = join(import.meta.dirname, '../../..'); +const BUILD = join(import.meta.dirname, '../dist/index.js'); +const SOURCES = ['apps/editor/src', 'apps/editor/server', 'packages', 'plugins', 'tooling']; +const SKIPPED = new Set([ + 'node_modules', + 'dist', + 'test', + 'scripts', + 'e2e', + 'coverage', + '.turbo', + '.svelte-kit', +]); + +/** The newest source file under a folder: code in `src` or `server`, and plugin manifests. */ +const newest = (folder: string): { path: string; time: number } => { + let found = { path: '', time: 0 }; + if (!existsSync(folder)) return found; + for (const entry of readdirSync(folder, { withFileTypes: true })) { + if (SKIPPED.has(entry.name)) continue; + const path = join(folder, entry.name); + const candidate = entry.isDirectory() + ? newest(path) + : (/\.(ts|svelte|css)$/.test(entry.name) && /[\\/](src|server)[\\/]/.test(path)) || + entry.name === 'nanoforge.manifest.json' + ? { path, time: statSync(path).mtimeMs } + : found; + if (candidate.time > found.time) found = candidate; + } + return found; +}; + +/** + * The e2e tests run the production build: they fail on a build older than the code, with the + * command that makes a new one (`E2E_ALLOW_STALE=1` runs them anyway). + */ +export const checkBuild = (): void => { + if (process.env.E2E_ALLOW_STALE === '1' || process.env.BENCH) return; + if (!existsSync(BUILD)) + throw new Error('No build of the editor: pnpm turbo build --filter=@nanoforge-dev/editor...'); + const built = statSync(BUILD).mtimeMs; + const source = SOURCES.map((folder) => newest(join(ROOT, folder))).reduce((a, b) => + b.time > a.time ? b : a, + ); + if (source.time > built) + throw new Error( + `The editor build is older than ${source.path.slice(ROOT.length + 1)}: run pnpm turbo build --filter=@nanoforge-dev/editor... (or E2E_ALLOW_STALE=1 to test the old build)`, + ); +}; diff --git a/apps/editor/e2e/code-editor.spec.ts b/apps/editor/e2e/code-editor.spec.ts index cfe0b098..09a0b619 100644 --- a/apps/editor/e2e/code-editor.spec.ts +++ b/apps/editor/e2e/code-editor.spec.ts @@ -145,6 +145,16 @@ test('asks what to do when a file changes on disk during unsaved edits', async ( await page.keyboard.press('Control+s'); await expect.poll(() => onDisk('conflict')).toContain('// mine'); expect(onDisk('conflict')).not.toContain('// theirs'); + + /** + * The closed diff editor's services are disposed: suggestions in the editor still work (they + * threw "InstantiationService has been disposed", caught by the page error fixture). + */ + await typeAtEnd(page, '\nNanoforgeFac'); + await page.keyboard.press('Control+Space'); + await expect(page.locator('.suggest-widget')).toBeVisible(); + await page.keyboard.press('Escape'); + await page.keyboard.press('Control+z'); }); test('autosaves after a delay when the setting is on', async ({ page }) => { diff --git a/apps/editor/e2e/command-palette.spec.ts b/apps/editor/e2e/command-palette.spec.ts index af0aa245..dbc7292b 100644 --- a/apps/editor/e2e/command-palette.spec.ts +++ b/apps/editor/e2e/command-palette.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject, resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -81,7 +81,7 @@ test('palette: runs commands, recent ones first, from where the focus was', asyn await page.keyboard.press('Escape'); await expect(box(page)).toHaveCount(0); await resetLayout(page); - await page.waitForTimeout(900); + await saved(page); }); test('palette: opens a file by a fuzzy name, goes to a line and to a symbol', async ({ page }) => { @@ -118,7 +118,7 @@ test('palette: opens a file by a fuzzy name, goes to a line and to a symbol', as await expect(cursorLine(page)).toHaveText(String(mainLine)); await page.getByRole('tab', { name: 'main.ts' }).click({ button: 'middle' }); - await page.waitForTimeout(900); + await saved(page); }); test('keymap: chords, conflicts, conditions, removals, presets, kept on this machine', async ({ diff --git a/apps/editor/e2e/console.spec.ts b/apps/editor/e2e/console.spec.ts index 8b501403..55591c75 100644 --- a/apps/editor/e2e/console.spec.ts +++ b/apps/editor/e2e/console.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject, resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { GAME, PONG } from './workspace'; @@ -87,7 +87,7 @@ test('console: groups, levels, search, repeats, values and file links', async ({ await chip(page, 'Plugins').click(); await expect(chip(page, 'Plugins')).toHaveAttribute('aria-pressed', 'false'); await page.getByRole('tab', { name: 'main.ts' }).click({ button: 'middle' }); - await page.waitForTimeout(900); + await saved(page); }); test('problems: a file that is not open, grouped by file, opens at its position', async ({ @@ -136,7 +136,7 @@ test('problems: a file that is not open, grouped by file, opens at its position' await page.getByRole('tab', { name: 'broken.ts' }).click({ button: 'middle' }); rmSync(BROKEN); await expect(file).toHaveCount(0, { timeout: 15_000 }); - await page.waitForTimeout(900); + await saved(page); }); test.describe('with the engine', () => { @@ -171,7 +171,7 @@ test.describe('with the engine', () => { await showPanel(page, 'Problems'); await expect(build).toHaveCount(0, { timeout: 30_000 }); await showPanel(page, 'Console'); - await page.waitForTimeout(900); + await saved(page); } finally { writeFileSync(MAIN, original); } @@ -219,7 +219,7 @@ test.describe('with the engine', () => { await run.getByRole('button', { name: 'Stop' }).click(); await expect(run.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 15_000 }); } - await page.waitForTimeout(900); + await saved(page); } finally { writeFileSync(MAIN, original); } diff --git a/apps/editor/e2e/ecs-live.spec.ts b/apps/editor/e2e/ecs-live.spec.ts index 93b97033..69c662e3 100644 --- a/apps/editor/e2e/ecs-live.spec.ts +++ b/apps/editor/e2e/ecs-live.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject, resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { GAME } from './workspace'; @@ -83,7 +83,7 @@ test('shows the live world, edits it, applies it to the code and pauses a system } finally { writeFileSync(MAIN, original); await resetLayout(page).catch(() => undefined); - await page.waitForTimeout(1500); + await saved(page).catch(() => undefined); } }); @@ -142,7 +142,7 @@ test('moves an entity by dragging it in the Game screen, and applies it to the c .click({ timeout: 2000 }) .catch(() => undefined); await resetLayout(page).catch(() => undefined); - await page.waitForTimeout(1500); + await saved(page).catch(() => undefined); } }); @@ -171,6 +171,6 @@ test('edits an engine enum param with a select (types from the engine packages)' } finally { writeFileSync(MAIN, original); await resetLayout(page).catch(() => undefined); - await page.waitForTimeout(1500); + await saved(page).catch(() => undefined); } }); diff --git a/apps/editor/e2e/ecs.spec.ts b/apps/editor/e2e/ecs.spec.ts index 118f5bd6..c197c88d 100644 --- a/apps/editor/e2e/ecs.spec.ts +++ b/apps/editor/e2e/ecs.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { expandFolders, openProject, resetLayout, showPanel } from './helpers'; +import { expandFolders, openProject, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -24,7 +24,7 @@ test.beforeEach(async ({ page }) => { test.afterEach(async ({ page }) => { writeFileSync(MAIN, original); await resetLayout(page); - await page.waitForTimeout(1500); + await saved(page); }); test('lists the entities of main.ts and edits a component param', async ({ page }) => { diff --git a/apps/editor/e2e/git.spec.ts b/apps/editor/e2e/git.spec.ts index f2e29039..fcd3fe1b 100644 --- a/apps/editor/e2e/git.spec.ts +++ b/apps/editor/e2e/git.spec.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject, resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG, WORKSPACE } from './workspace'; @@ -51,6 +51,12 @@ const cleanup = () => { rmSync(join(PONG, NEW_FILE), { force: true }); }; test.beforeEach(cleanup); +/** The shown panels are saved with the layout: put Files and Console back. */ +test.afterEach(async ({ page }) => { + await showPanel(page, 'Files').catch(() => undefined); + await showPanel(page, 'Console').catch(() => undefined); + await saved(page).catch(() => undefined); +}); test.afterEach(async () => { for (let clean = 0, attempt = 0; clean < 2 && attempt < 20; attempt++) { clean = existsSync(join(PONG, '.git')) ? 0 : clean + 1; @@ -59,22 +65,33 @@ test.afterEach(async () => { } }); -test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash', async ({ - page, -}) => { - test.setTimeout(150_000); +/** A repository with every file of the project in one commit, as `git` makes it. */ +const initRepository = () => { + git(PONG, 'init', '--quiet', '-b', 'main'); + git(PONG, 'config', 'user.name', 'E2E'); + git(PONG, 'config', 'user.email', 'e2e@example.com'); + git(PONG, 'add', '-A'); + git(PONG, 'commit', '--quiet', '-m', 'first commit'); +}; +/** Opens pong on the full layout, with the Commit panel shown. */ +const open = async (page: Page) => { await openProject(page, 'pong'); await page.waitForURL(/\/project\//); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await resetLayout(page); - const commitButton = commitPanel(page).getByRole('button', { name: 'Commit', exact: true }); - const push = async () => { - const dialog = page.getByRole('dialog', { name: 'Push commits' }); - await expect(dialog).toBeVisible(); - return dialog; - }; - await showPanel(page, 'Commit'); +}; +const commitButton = (page: Page) => + commitPanel(page).getByRole('button', { name: 'Commit', exact: true }); +const pushDialog = async (page: Page) => { + const dialog = page.getByRole('dialog', { name: 'Push commits' }); + await expect(dialog).toBeVisible(); + return dialog; +}; +const changed = (page: Page) => row(page, 'Changes', FILE); + +test('initializes a repository, commits what is checked and shows the log', async ({ page }) => { + await open(page); await expect(commitPanel(page).getByText('This project is not a git repository')).toBeVisible(); await vcs(page, 'Initialize repository'); await expect(group(page, 'Unversioned files')).toBeVisible(); @@ -85,7 +102,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await expect(nested).toContainText('main.ts'); await expect(nested).toContainText('apps/client/src'); await message(page).fill('first commit'); - await expect(commitButton).toBeDisabled(); + await expect(commitButton(page)).toBeDisabled(); const includeAll = group(page, 'Unversioned files').getByRole('checkbox', { name: 'Include all: Unversioned files', includeHidden: true, @@ -105,11 +122,16 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash const details = logPanel(page).getByRole('complementary', { name: 'Commit details' }); await expect(details.getByRole('heading', { name: 'first commit' })).toBeVisible(); await expect(details.getByRole('button', { name: 'package.json', exact: true })).toBeVisible(); +}); +test('shows a change in the Files panel and as a diff, and keeps the panel state', async ({ + page, +}) => { + initRepository(); + await open(page); writeFileSync(join(PONG, FILE), 'renamed\n'); - const changed = row(page, 'Changes', FILE); - await expect(changed).toContainText('rename-me.txt'); - await expect(changed.getByRole('checkbox', { name: `Include ${FILE}` })).toBeChecked(); + await expect(changed(page)).toContainText('rename-me.txt'); + await expect(changed(page).getByRole('checkbox', { name: `Include ${FILE}` })).toBeChecked(); await showPanel(page, 'Files'); const files = page.getByRole('tree', { name: 'Project files' }); const folder = files.getByRole('treeitem', { name: /^scratch/ }); @@ -119,7 +141,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await expect(files.getByRole('treeitem', { name: /^rename-me\.txt/ })).toContainText('M'); await showPanel(page, 'Commit'); - await changed.dblclick(); + await changed(page).dblclick(); await expect(page.getByLabel('Last commit and the file')).toBeVisible(); await expect( page.getByRole('status').filter({ hasText: 'compared with: Last commit' }), @@ -131,24 +153,32 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await group(page, 'Changes') .getByRole('button', { name: 'Collapse Changes', includeHidden: true }) .click(); - await expect(changed).toHaveCount(0); + await expect(changed(page)).toHaveCount(0); await expect(group(page, 'Changes')).toContainText('1 file'); await group(page, 'Changes') .getByRole('button', { name: 'Expand Changes', includeHidden: true }) .click(); - await expect(changed).toBeVisible(); + await expect(changed(page)).toBeVisible(); - await changed.getByRole('checkbox', { name: `Include ${FILE}` }).uncheck(); + await changed(page) + .getByRole('checkbox', { name: `Include ${FILE}` }) + .uncheck(); await message(page).fill('nothing'); await commitPanel(page) .getByRole('tab', { name: /^Stash/ }) .click(); await commitPanel(page).getByRole('tab', { name: 'Commit', exact: true }).click(); await expect(message(page)).toHaveValue('nothing'); - await expect(changed.getByRole('checkbox', { name: `Include ${FILE}` })).not.toBeChecked(); - await expect(commitButton).toBeDisabled(); + await expect(changed(page).getByRole('checkbox', { name: `Include ${FILE}` })).not.toBeChecked(); + await expect(commitButton(page)).toBeDisabled(); await message(page).fill(''); - await changed.getByRole('checkbox', { name: `Include ${FILE}` }).check(); +}); + +test('selects changes, rolls them back, adds and deletes an unversioned file', async ({ page }) => { + initRepository(); + await open(page); + writeFileSync(join(PONG, FILE), 'renamed\n'); + await expect(changed(page)).toBeVisible(); const rollBack = commitPanel(page).getByRole('button', { name: 'Rollback' }); await commitPanel(page) @@ -156,13 +186,13 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash .click({ position: { x: 100, y: 200 }, }); - await expect(changed).toHaveAttribute('aria-selected', 'false'); + await expect(changed(page)).toHaveAttribute('aria-selected', 'false'); await expect(rollBack).toBeDisabled(); writeFileSync(join(PONG, OTHER_FILE), 'moved\n'); const alsoChanged = row(page, 'Changes', OTHER_FILE); await expect(alsoChanged).toBeVisible(); await group(page, 'Changes').getByText('Changes', { exact: true }).click(); - await expect(changed).toHaveAttribute('aria-selected', 'true'); + await expect(changed(page)).toHaveAttribute('aria-selected', 'true'); await expect(alsoChanged).toHaveAttribute('aria-selected', 'true'); await alsoChanged.click({ modifiers: ['Control'] }); await expect(alsoChanged).toHaveAttribute('aria-selected', 'false'); @@ -170,23 +200,25 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await rollBack.click(); await expect(page.getByRole('heading', { name: 'Rollback the 2 selected files?' })).toBeVisible(); await page.getByRole('button', { name: 'Rollback', exact: true }).last().click(); - await expect(changed).toHaveCount(0); + await expect(changed(page)).toHaveCount(0); await expect(alsoChanged).toHaveCount(0); expect(readFileSync(join(PONG, FILE), 'utf8')).toBe(original); expect(readFileSync(join(PONG, OTHER_FILE), 'utf8')).toBe(otherOriginal); + writeFileSync(join(PONG, FILE), 'again\n'); - await changed.click(); + await changed(page).click(); await page.keyboard.press('Space'); - await expect(changed.getByRole('checkbox')).not.toBeChecked(); + await expect(changed(page).getByRole('checkbox')).not.toBeChecked(); await page.keyboard.press('Space'); - await expect(changed.getByRole('checkbox')).toBeChecked(); - await changed.click({ button: 'right' }); + await expect(changed(page).getByRole('checkbox')).toBeChecked(); + await changed(page).click({ button: 'right' }); await expect(page.getByRole('menuitem', { name: /^Show diff/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /^Jump to source/ })).toBeVisible(); await page.getByRole('menuitem', { name: /^Rollback…/ }).click(); await expect(page.getByRole('heading', { name: `Rollback ${FILE}?` })).toBeVisible(); await page.getByRole('button', { name: 'Rollback', exact: true }).last().click(); - await expect(changed).toHaveCount(0); + await expect(changed(page)).toHaveCount(0); + writeFileSync(join(PONG, NEW_FILE), 'new\n'); await row(page, 'Unversioned files', NEW_FILE).click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Add to VCS' }).click(); @@ -198,7 +230,12 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await page.keyboard.press('Delete'); await page.getByRole('button', { name: 'Delete', exact: true }).last().click(); await expect(row(page, 'Unversioned files', NEW_FILE)).toHaveCount(0); +}); +test('creates, checks out and deletes a branch', async ({ page }) => { + initRepository(); + await open(page); + await showPanel(page, 'Git'); await vcs(page, 'New branch…'); await page.getByRole('textbox', { name: 'Branch name' }).fill('feature'); await page.getByRole('button', { name: 'Create branch' }).click(); @@ -211,17 +248,25 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await page.getByRole('menuitem', { name: 'Delete', exact: true }).click(); await page.getByRole('button', { name: 'Delete', exact: true }).last().click(); await expect(local.getByRole('button', { name: 'feature', exact: true })).toHaveCount(0); +}); +test('pushes, fetches, filters the log, merges a conflict and pushes the merge', async ({ + page, +}) => { + test.setTimeout(90_000); + initRepository(); + await open(page); + await showPanel(page, 'Git'); git(WORKSPACE, 'init', '--quiet', '--bare', '-b', 'main', REMOTE); git(PONG, 'remote', 'add', 'origin', REMOTE); const shown = logPanel(page).getByRole('status', { name: 'Branch shown' }); await vcs(page, 'Push…'); - const pushDialog = await push(); - await expect(pushDialog).toContainText('main → origin/main'); - await expect(pushDialog.getByRole('list', { name: 'Commits to push' })).toContainText( + const firstPush = await pushDialog(page); + await expect(firstPush).toContainText('main → origin/main'); + await expect(firstPush.getByRole('list', { name: 'Commits to push' })).toContainText( 'first commit', ); - await pushDialog.getByRole('button', { name: 'Push', exact: true }).click(); + await firstPush.getByRole('button', { name: 'Push', exact: true }).click(); await expect(shown).toHaveText('Branch: main ↑0 ↓0'); git(WORKSPACE, 'clone', '--quiet', REMOTE, OTHER); git(OTHER, 'config', 'user.name', 'Other'); @@ -243,7 +288,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await expect(commits(page).getByRole('row', { name: /their change/ })).toHaveCount(0); writeFileSync(join(PONG, FILE), 'mine\n'); - await expect(changed).toBeVisible(); + await expect(changed(page)).toBeVisible(); await commit(page, 'my change'); await expect(branchStatus(page, 'main ↑1 ↓1')).toBeVisible(); await vcs(page, 'Update project'); @@ -254,31 +299,43 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await conflict.getByRole('button', { name: 'Mark resolved' }).click(); await expect(group(page, 'Merge conflicts')).toHaveCount(0); await commit(page, 'merge', 'Commit and push…'); - const afterMerge = await push(); + const afterMerge = await pushDialog(page); await expect(afterMerge.getByRole('list', { name: 'Commits to push' })).toContainText('merge'); await afterMerge.getByRole('button', { name: 'Push', exact: true }).click(); await expect(branchStatus(page, 'main')).toBeVisible(); await expect(commits(page).getByRole('row').first()).toContainText('merge'); +}); - writeFileSync(join(PONG, FILE), 'both, amended\n'); - await expect(changed).toBeVisible(); +test('amends the last commit from its message, and offers earlier messages', async ({ page }) => { + initRepository(); + await open(page); + await showPanel(page, 'Git'); + writeFileSync(join(PONG, FILE), 'mine\n'); + await expect(changed(page)).toBeVisible(); + await commit(page, 'my change'); + writeFileSync(join(PONG, FILE), 'mine, amended\n'); + await expect(changed(page)).toBeVisible(); await commitPanel(page).getByRole('checkbox', { name: 'Amend' }).check(); - await expect(message(page)).toHaveValue('merge'); - await commit(page, 'merge, amended'); - await expect(commits(page).getByRole('row').first()).toContainText('merge, amended'); - await expect(changed).toHaveCount(0); + await expect(message(page)).toHaveValue('my change'); + await commit(page, 'my change, amended'); + await expect(commits(page).getByRole('row').first()).toContainText('my change, amended'); + await expect(changed(page)).toHaveCount(0); await commitPanel(page).getByRole('button', { name: 'Commit message history' }).click(); - await page.getByRole('menuitem', { name: 'my change' }).click(); + await page.getByRole('menuitem', { name: 'my change', exact: true }).click(); await expect(message(page)).toHaveValue('my change'); await message(page).fill(''); +}); +test('stashes changes and pops them back', async ({ page }) => { + initRepository(); + await open(page); writeFileSync(join(PONG, FILE), 'work in progress\n'); - await expect(changed).toBeVisible(); + await expect(changed(page)).toBeVisible(); await vcs(page, 'Stash changes…'); await page.getByRole('textbox', { name: 'Message (optional)' }).fill('wip'); await page.getByRole('button', { name: 'Stash', exact: true }).click(); - await expect(changed).toHaveCount(0); - expect(readFileSync(join(PONG, FILE), 'utf8')).toBe('both, amended\n'); + await expect(changed(page)).toHaveCount(0); + expect(readFileSync(join(PONG, FILE), 'utf8')).toBe(original); await commitPanel(page) .getByRole('tab', { name: /^Stash/ }) .click(); @@ -286,10 +343,6 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await stashed.getByRole('button', { name: 'Pop' }).click(); await expect(commitPanel(page).getByText('No stash.')).toBeVisible(); expect(readFileSync(join(PONG, FILE), 'utf8')).toBe('work in progress\n'); - - await showPanel(page, 'Files'); - await showPanel(page, 'Console'); - await page.waitForTimeout(900); }); test('browses, folds and includes changes from the keyboard', async ({ page }) => { @@ -331,6 +384,5 @@ test('browses, folds and includes changes from the keyboard', async ({ page }) = await page.keyboard.press('ArrowLeft'); await expect.poll(active).toMatch(/^Unversioned files, /); await resetLayout(page); - /** The layout is saved after 500 ms. */ - await page.waitForTimeout(900); + await saved(page); }); diff --git a/apps/editor/e2e/helpers.ts b/apps/editor/e2e/helpers.ts index b9f53c0f..24c84bd2 100644 --- a/apps/editor/e2e/helpers.ts +++ b/apps/editor/e2e/helpers.ts @@ -1,5 +1,17 @@ import { type Page, expect } from '@playwright/test'; +/** + * Waits until the editor has saved everything that outlives the test (the layout, panel states, + * settings and their account push): the page's `data-nf-saved`. Two frames first, so a save the + * last action starts is counted. + */ +export const saved = async (page: Page): Promise => { + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + await expect(page.locator('html[data-nf-saved="true"]')).toHaveCount(1, { timeout: 15_000 }); +}; + /** * Puts the saved layout of the project back to the default with every panel (tests reuse * projects, and expect the panels of the full layout). diff --git a/apps/editor/e2e/keyboard.spec.ts b/apps/editor/e2e/keyboard.spec.ts index 0b0cb015..270342ab 100644 --- a/apps/editor/e2e/keyboard.spec.ts +++ b/apps/editor/e2e/keyboard.spec.ts @@ -1,4 +1,4 @@ -import { expandFolders, openProject, resetLayout } from './helpers'; +import { expandFolders, openProject, resetLayout, saved } from './helpers'; import { expect, test } from './test'; test.beforeEach(async ({ page }) => { @@ -9,8 +9,7 @@ test.beforeEach(async ({ page }) => { test.afterEach(async ({ page }) => { await resetLayout(page); - /** The layout and the Files panel's state are saved after 500 ms. */ - await page.waitForTimeout(900); + await saved(page); }); test('moves between tool window icons with the arrow keys', async ({ page }) => { diff --git a/apps/editor/e2e/manual-pass.spec.ts b/apps/editor/e2e/manual-pass.spec.ts index 176d6cbb..6bff33b0 100644 --- a/apps/editor/e2e/manual-pass.spec.ts +++ b/apps/editor/e2e/manual-pass.spec.ts @@ -1,7 +1,7 @@ import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { expandFolders, openProject, panelTab, resetLayout, showPanel } from './helpers'; +import { expandFolders, openProject, panelTab, resetLayout, saved, showPanel } from './helpers'; import { expect, test } from './test'; import { PONG } from './workspace'; @@ -19,7 +19,7 @@ test.afterEach(async ({ page }) => { rmSync(NOTES, { force: true }); rmSync(TODO, { force: true }); await resetLayout(page); - await page.waitForTimeout(1500); + await saved(page); }); test('an image opens in a preview, not as text in the code editor', async ({ page }) => { diff --git a/apps/editor/e2e/marketplace.spec.ts b/apps/editor/e2e/marketplace.spec.ts index 97cf96ce..48b89cd5 100644 --- a/apps/editor/e2e/marketplace.spec.ts +++ b/apps/editor/e2e/marketplace.spec.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { openProject, resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG, REGISTRY, WORKSPACE } from './workspace'; @@ -199,5 +199,5 @@ test('installs a package with its dependency, updates it and uninstalls it', asy await showPanel(page, 'Components'); await expect(components.getByRole('button', { name: 'C Hull', exact: true })).toHaveCount(0); await resetLayout(page); - await page.waitForTimeout(1500); + await saved(page); }); diff --git a/apps/editor/e2e/prepare-workspace.mjs b/apps/editor/e2e/prepare-workspace.mjs index acdc1777..84b929a7 100644 --- a/apps/editor/e2e/prepare-workspace.mjs +++ b/apps/editor/e2e/prepare-workspace.mjs @@ -16,6 +16,7 @@ import { dirname, join, resolve } from 'node:path'; import { bridgeOf, + cliRoot, engineRoot, linkEditorLibrary, linkEngineModules, @@ -191,7 +192,7 @@ if (existsSync(join(pong, 'node_modules/.bin/nf')) && bridge) { const game = join(root, 'game'); copyGame(pong, game); symlinkSync(join(engine, 'node_modules'), join(root, 'node_modules')); - const cli = process.env.NANOFORGE_CLI && resolve(process.env.NANOFORGE_CLI); + const cli = cliRoot(join(import.meta.dirname, '../../..')); if (cli && existsSync(join(cli, 'dist/command.loader.js'))) { const local = join(game, 'node_modules/@nanoforge-dev/cli'); rmSync(local, { recursive: true, force: true }); @@ -279,3 +280,20 @@ if (existsSync(join(pong, 'node_modules/.bin/nf')) && bridge) { `No built engine with the editor bridge at ${engine}: the runtime e2e test is skipped.`, ); } + +/** + * `E2E_REQUIRE_ENGINE=1` (CI): every project the engine tests play must be there, or the run + * fails here instead of skipping them. + */ +if (process.env.E2E_REQUIRE_ENGINE === '1') { + const missing = [ + ['game', 'a built engine with the editor bridge (NANOFORGE_ENGINE)'], + ['my-first-game', 'a built CLI (NANOFORGE_CLI)'], + ['breakout', "the engine's built scene module"], + ].filter(([folder]) => !existsSync(join(root, folder, 'package.json'))); + if (missing.length) { + for (const [folder, need] of missing) + console.error(`E2E_REQUIRE_ENGINE: no ${folder} project, it needs ${need}`); + process.exit(1); + } +} diff --git a/apps/editor/e2e/project.spec.ts b/apps/editor/e2e/project.spec.ts index c785ee09..80be345d 100644 --- a/apps/editor/e2e/project.spec.ts +++ b/apps/editor/e2e/project.spec.ts @@ -88,6 +88,8 @@ test('adds a shared library, chooses the apps that use it, renames and removes i await page.getByRole('dialog').getByRole('textbox', { name: 'Name' }).fill('Score'); await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click(); await expect(components.getByRole('button', { name: 'C Score', exact: true })).toBeVisible(); + /** The new component's file opens in the Script screen: go back to Project once it has. */ + await expect(page.getByRole('tab', { name: /^score\.ts/ })).toBeVisible(); await page.getByRole('tab', { name: 'Project', exact: true }).click(); await expect(card('@pong-network/shared')).toContainText('1 component or system'); diff --git a/apps/editor/e2e/runtime.spec.ts b/apps/editor/e2e/runtime.spec.ts index 93d66424..1db902b9 100644 --- a/apps/editor/e2e/runtime.spec.ts +++ b/apps/editor/e2e/runtime.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject } from './helpers'; +import { openProject, saved } from './helpers'; import { type Page, expect, test } from './test'; import { GAME } from './workspace'; @@ -60,7 +60,7 @@ test('plays pong-network server and client, pauses, resumes and stops, three tim await expect(output.getByText('did not stop')).toHaveCount(0); expect(errors).toEqual([]); await page.getByRole('button', { name: /^Editor/ }).click(); - await page.waitForTimeout(900); + await saved(page); }); test('offers to add the editor library to a game that does not register it', async ({ page }) => { diff --git a/apps/editor/e2e/scene.spec.ts b/apps/editor/e2e/scene.spec.ts index 8d40d0c3..7785028b 100644 --- a/apps/editor/e2e/scene.spec.ts +++ b/apps/editor/e2e/scene.spec.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject, resetLayout, showPanel } from './helpers'; +import { openProject, resetLayout, saved, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { BREAKOUT } from './workspace'; @@ -48,7 +48,7 @@ test.describe('scenes', () => { for (const path of snapshot().keys()) if (!original.has(path)) rmSync(path); for (const [path, text] of original) writeFileSync(path, text); await resetLayout(page); - await page.waitForTimeout(1500); + await saved(page); }); test('shows the scene tree, and the ECS widgets edit the selected scene', async ({ page }) => { @@ -238,13 +238,6 @@ test.describe('scenes', () => { const run = (page: Page, name: string) => page.getByRole('toolbar', { name: 'Run' }).getByRole('button', { name }); - test.afterEach(async ({ page }) => { - if (await run(page, 'Stop').isVisible()) { - await run(page, 'Stop').click(); - await expect(run(page, 'Play')).toBeVisible({ timeout: 15_000 }); - } - }); - test('shows the loaded scenes and vars, loads a scene and sets a var', async ({ page }) => { await run(page, 'Play').click(); await expect(page.locator('[data-nf-game] canvas').first()).toBeVisible({ timeout: 60_000 }); diff --git a/apps/editor/e2e/test.ts b/apps/editor/e2e/test.ts index d0d26ae8..124c76fc 100644 --- a/apps/editor/e2e/test.ts +++ b/apps/editor/e2e/test.ts @@ -9,7 +9,7 @@ const IGNORED = new Set(['Canceled']); * The `test` of every spec: it fails when a page of the test throws an uncaught error * (a Svelte effect loop or a rejected promise doesn't break what the test looks at). */ -export const test = base.extend<{ pageErrors: string[] }>({ +export const test = base.extend<{ pageErrors: string[]; stopsTheGame: undefined }>({ pageErrors: [ async ({ context }, use) => { const errors: string[] = []; @@ -28,4 +28,22 @@ export const test = base.extend<{ pageErrors: string[] }>({ }, { auto: true }, ], + /** + * A game still playing when a test ends keeps its server running on the editor's server, and + * the next test's game then fails to start: every test stops the game it left playing. + */ + stopsTheGame: [ + async ({ page }, use) => { + await use(undefined); + const run = page.getByRole('toolbar', { name: 'Run' }); + const stop = run.getByRole('button', { name: /^Stop/ }); + if (!(await stop.isVisible().catch(() => false))) return; + await stop.click({ timeout: 5_000 }).catch(() => undefined); + await base + .expect(run.getByRole('button', { name: /^Play/ })) + .toBeVisible({ timeout: 15_000 }) + .catch(() => undefined); + }, + { auto: true }, + ], }); diff --git a/apps/editor/e2e/tips.spec.ts b/apps/editor/e2e/tips.spec.ts index 5c85fa1e..ae59d95f 100644 --- a/apps/editor/e2e/tips.spec.ts +++ b/apps/editor/e2e/tips.spec.ts @@ -3,6 +3,7 @@ import { openProject, putBackAccountSetting, resetLayout, + saved, showPanel, } from './helpers'; import { expect, test } from './test'; @@ -30,8 +31,7 @@ test('a panel tip shows until put away, and stays away', async ({ page }) => { ); await tip.getByRole('button', { name: 'Got it' }).click(); await expect(tip).toHaveCount(0); - /** Account settings are pushed after 500 ms. */ - await page.waitForTimeout(900); + await saved(page); await page.reload(); await showPanel(page, 'History'); await expect(page.getByRole('region', { name: 'History', exact: true })).toBeVisible(); diff --git a/apps/editor/e2e/workbench.spec.ts b/apps/editor/e2e/workbench.spec.ts index cb53ca16..1af2cd0c 100644 --- a/apps/editor/e2e/workbench.spec.ts +++ b/apps/editor/e2e/workbench.spec.ts @@ -1,4 +1,4 @@ -import { expandFolders, openProject, panelTab, resetLayout, showPanel } from './helpers'; +import { expandFolders, openProject, panelTab, resetLayout, saved, showPanel } from './helpers'; import { type Locator, type Page, expect, test } from './test'; const openPong = async (page: Page) => { @@ -90,8 +90,7 @@ test('moves, resizes and floats docks, undoes, and restores the layout after rel await drag(page, panelTab(page, 'Console'), { x: 700, y: 300 }); await expect(page.getByRole('dialog', { name: 'Floating panel' })).toBeVisible(); - /** Layout saves are debounced. */ - await page.waitForTimeout(900); + await saved(page); await page.reload(); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await expect(page.getByRole('dialog', { name: 'Floating panel' })).toBeVisible(); @@ -139,8 +138,7 @@ test('shows and hides panels from the tool window stripes, and docks one bottom await expect(page.getByRole('region', { name: 'Console' })).toBeVisible(); await resetLayout(page); - /** Layout saves are debounced. */ - await page.waitForTimeout(900); + await saved(page); }); test('runs a local plugin built against the shared runtime', async ({ page }) => { @@ -219,7 +217,7 @@ test('a simple layout shows the main panels only, all panels come back from the await expect(stripes.getByRole('tab', { name: 'Commit', exact: true })).toBeVisible(); } finally { await resetLayout(page); - await page.waitForTimeout(900); + await saved(page); } }); diff --git a/apps/editor/package.json b/apps/editor/package.json index 4a19f47a..25bb231b 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -45,9 +45,11 @@ "lint": "prettier --check . && eslint --format=pretty .", "format": "prettier --write . && eslint --fix --format=pretty src", "test:unit": "vitest run --config ../../vitest.config.ts", + "test:coverage": "vitest run --config ../../vitest.config.ts --coverage", "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'apps/editor/*' --include-path 'packages/*' --include-path 'plugins/*'", "release": "cliff-jumper", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "e2e:last-failure": "node ./scripts/last-failure.mjs" }, "dependencies": { "chokidar": "catalog:server", diff --git a/apps/editor/playwright.config.ts b/apps/editor/playwright.config.ts index 0f3eaf15..6e3d1287 100644 --- a/apps/editor/playwright.config.ts +++ b/apps/editor/playwright.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from '@playwright/test'; import { join } from 'node:path'; +import { checkBuild } from './e2e/check-build'; + const PORT = 4790; const root = import.meta.dirname; /** Folder of `e2e/` holding the test projects (`E2E_WORKSPACE`, see `e2e/workspace.ts`). */ @@ -24,6 +26,12 @@ const others = (process.env.BROWSERS ?? 'chromium,firefox') (browser): browser is 'firefox' | 'webkit' => browser === 'firefox' || browser === 'webkit', ); +/** + * Before the server starts, in the main process of `playwright test` only: workers load the + * config too, and so do tools that read it (knip, editors). + */ +if (!process.env.TEST_WORKER_INDEX && process.argv.includes('test')) checkBuild(); + export default defineConfig({ testDir: 'e2e', testMatch: process.env.BENCH ? '*.bench.ts' : '*.spec.ts', diff --git a/apps/editor/scripts/last-failure.mjs b/apps/editor/scripts/last-failure.mjs new file mode 100644 index 00000000..e58928c3 --- /dev/null +++ b/apps/editor/scripts/last-failure.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * The last failed e2e test: prints its `error-context.md` (error, page snapshot, code) and + * saves the last frame of its trace as `test-results/last-failure.jpeg`. + */ +import { execFileSync } from 'node:child_process'; +import { + copyFileSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const RESULTS = join(import.meta.dirname, '../test-results'); +const failures = existsSync(RESULTS) + ? readdirSync(RESULTS) + .map((name) => join(RESULTS, name)) + .filter((folder) => existsSync(join(folder, 'error-context.md'))) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs) + : []; +const [last] = failures; +if (!last) { + console.log('No failed test in test-results/.'); + process.exit(0); +} +console.log(`${failures.length} failed test(s); the last one: ${last}\n`); +console.log(readFileSync(join(last, 'error-context.md'), 'utf8')); + +const trace = join(last, 'trace.zip'); +if (existsSync(trace)) { + const folder = mkdtempSync(join(tmpdir(), 'nf-trace-')); + try { + execFileSync('unzip', ['-q', '-o', trace, 'screencast/*', '-d', folder]); + /** Frames are named `page@-