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..b4207444 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
- - 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
+ run: pnpm --filter @nanoforge-dev/editor-project exec vitest run --config vitest.config.ts --project browser
+ env:
+ BROWSERS: firefox
+
+ - 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..44ccf255
--- /dev/null
+++ b/apps/editor/e2e/check-build.ts
@@ -0,0 +1,59 @@
+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',
+ 'apps/editor/tutorial',
+ '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..c73a5b49 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 });
@@ -252,6 +253,10 @@ if (existsSync(join(pong, 'node_modules/.bin/nf')) && bridge) {
linkNodeModules(join(pong, 'apps/client/node_modules'), join(tutorial, 'node_modules'));
rmSync(join(tutorial, 'node_modules/@nanoforge-dev/cli'), { recursive: true, force: true });
symlinkSync(cli, join(tutorial, 'node_modules/@nanoforge-dev/cli'));
+ /** Builds run the project's own `nf`: without it they need one on the PATH, which CI has not. */
+ mkdirSync(join(tutorial, 'node_modules/.bin'), { recursive: true });
+ rmSync(join(tutorial, 'node_modules/.bin/nf'), { force: true });
+ symlinkSync(join(cli, 'dist/nf.js'), join(tutorial, 'node_modules/.bin/nf'));
symlinkSync(
realpathSync(join(pong, 'node_modules/@nanoforge-dev/config')),
join(tutorial, 'node_modules/@nanoforge-dev/config'),
@@ -279,3 +284,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@-.jpeg`. */
+ const time = (name) => Number(/-(\d+)\.jpeg$/.exec(name)?.[1] ?? 0);
+ const frames = readdirSync(join(folder, 'screencast'))
+ .filter((name) => name.endsWith('.jpeg'))
+ .sort((a, b) => time(a) - time(b))
+ .map((name) => join(folder, 'screencast', name));
+ const frame = frames.at(-1);
+ if (frame) {
+ const target = join(RESULTS, 'last-failure.jpeg');
+ copyFileSync(frame, target);
+ console.log(`Last frame: ${target}`);
+ }
+ } catch (error) {
+ console.log(`Could not read the trace (${error.message}): npx playwright show-trace ${trace}`);
+ } finally {
+ rmSync(folder, { recursive: true, force: true });
+ }
+}
diff --git a/apps/editor/src/lib/editor/create-editor.ts b/apps/editor/src/lib/editor/create-editor.ts
index 4ff0a34d..da7cda39 100644
--- a/apps/editor/src/lib/editor/create-editor.ts
+++ b/apps/editor/src/lib/editor/create-editor.ts
@@ -58,6 +58,7 @@ import { followProjectCaches } from '../project/follow-project-caches';
import { followProjectTrust } from '../project/follow-project-trust';
import { followProjectRuntime } from '../runtime/follow-project-runtime';
import { WorkspaceActions, WorkspaceActionsToken } from '../workspace/workspace-actions';
+import { followSaved } from './follow-saved';
import { registerAppContributions } from './register-app-contributions';
/** How long startup waits for the editor server before saying it does not answer. */
@@ -142,6 +143,7 @@ export const createEditor = async (
);
const { settings } = settingsSetup;
services.provide(AccountSyncToken, settingsSetup.account);
+ store.add(followSaved(settings, settingsSetup.account));
store.add(followProjectCaches({ services, projects, settings, session }));
const history = store.add(
diff --git a/apps/editor/src/lib/editor/follow-saved.ts b/apps/editor/src/lib/editor/follow-saved.ts
new file mode 100644
index 00000000..aa962be8
--- /dev/null
+++ b/apps/editor/src/lib/editor/follow-saved.ts
@@ -0,0 +1,24 @@
+import { type Disposable, toDisposable } from '@nanoforge-dev/editor-kernel';
+import type { AccountSyncStore, SettingsService } from '@nanoforge-dev/editor-settings';
+
+/**
+ * Marks the page `data-nf-saved="true"` while nothing waits to be saved: no settings write or
+ * debounced layout save, no account push. The e2e tests wait for it instead of sleeping.
+ */
+export const followSaved = (
+ settings: SettingsService,
+ account: AccountSyncStore,
+ root: HTMLElement = document.documentElement,
+): Disposable => {
+ const update = () => {
+ const status = account.status.get();
+ const saved = settings.pending.get() === 0 && status !== 'pending' && status !== 'syncing';
+ root.dataset.nfSaved = String(saved);
+ };
+ update();
+ const stops = [settings.pending.subscribe(update), account.status.subscribe(update)];
+ return toDisposable(() => {
+ for (const stop of stops) stop();
+ delete root.dataset.nfSaved;
+ });
+};
diff --git a/apps/editor/src/lib/kit/KitPlayground.svelte b/apps/editor/src/lib/kit/KitPlayground.svelte
new file mode 100644
index 00000000..faf8aa9a
--- /dev/null
+++ b/apps/editor/src/lib/kit/KitPlayground.svelte
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+ {#each [nanoforgeDark, nanoforgeLight] as theme (theme.id)}
+
+ {/each}
+
+
+
+ (dialog = false)}>
+ The menu, the select and the tooltip open above this dialog.
+
+
{} },
+ { kind: 'item', id: 'b', label: 'Another entry', onSelect: () => {} },
+ ]}
+ >
+ {#snippet trigger({ props })}
+ Menu
+ {/snippet}
+
+ (side = value)}
+ />
+
+ {#snippet children({ props })}
+ Tooltip
+ {/snippet}
+
+ notifications.notify('warning', 'Above the dialog too')}>Toast
+
+ {#snippet footer()}
+ (dialog = false)}>Close
+ {/snippet}
+
+
+
+
+
diff --git a/apps/editor/src/lib/kit/KitSamples.svelte b/apps/editor/src/lib/kit/KitSamples.svelte
new file mode 100644
index 00000000..d035af49
--- /dev/null
+++ b/apps/editor/src/lib/kit/KitSamples.svelte
@@ -0,0 +1,223 @@
+
+
+
+
+ Buttons
+
+ Play
+ Secondary
+ Ghost
+ Delete
+ Disabled
+
+
+
+
+
+
+
+
+ Tabs, menu, tooltip
+ (tab = id)}
+ />
+
+
+ {#snippet trigger({ props })}
+ File ({name})
+ {/snippet}
+
+
+ {#snippet children({ props })}
+ Hover me ({name})
+ {/snippet}
+
+
+
+
+
+
+
+ Property grid
+ (values[key] = value)}
+ />
+
+
+
+ Empty state
+
+ {#snippet actions()}
+ New entity
+ {/snippet}
+
+
+
+
+
diff --git a/apps/editor/src/routes/kit/+page.svelte b/apps/editor/src/routes/kit/+page.svelte
new file mode 100644
index 00000000..94b34450
--- /dev/null
+++ b/apps/editor/src/routes/kit/+page.svelte
@@ -0,0 +1,7 @@
+
+
+Kit — NanoForge
+
+
diff --git a/apps/editor/src/routes/kit/+page.ts b/apps/editor/src/routes/kit/+page.ts
new file mode 100644
index 00000000..990cd468
--- /dev/null
+++ b/apps/editor/src/routes/kit/+page.ts
@@ -0,0 +1,12 @@
+import { error } from '@sveltejs/kit';
+
+import { dev } from '$app/environment';
+
+/**
+ * The kit playground is for development: a production build has no `/kit`, and no playground
+ * code (the import sits in a branch the build drops).
+ */
+export const load = async () => {
+ if (dev) return { KitPlayground: (await import('$lib/kit/KitPlayground.svelte')).default };
+ error(404, 'Not found');
+};
diff --git a/apps/editor/test/adoption/adoption-service.spec.ts b/apps/editor/test/adoption/adoption-service.spec.ts
new file mode 100644
index 00000000..460af753
--- /dev/null
+++ b/apps/editor/test/adoption/adoption-service.spec.ts
@@ -0,0 +1,143 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ Container,
+ ContextKeyService,
+ CoreServices,
+ ExtensionRegistry,
+ ObservableValue,
+} from '@nanoforge-dev/editor-kernel';
+import { PROJECT_CHECKS, type ProjectSuggestion } from '@nanoforge-dev/editor-project';
+
+import { AdoptionService } from '../../src/lib/adoption/adoption-service';
+
+const suggestion = (id: string, files: string[] = ['a.ts']): ProjectSuggestion => ({
+ id,
+ title: id,
+ detail: '',
+ fix: {
+ title: `Fix ${id}`,
+ changeset: async () =>
+ ({ label: id, files: files.map((path) => ({ path, before: '', after: 'x' })) }) as never,
+ },
+});
+
+const setup = ({ apply = true, repository = false } = {}) => {
+ const services = new Container('test');
+ const extensions = new ExtensionRegistry();
+ const contextKeys = new ContextKeyService();
+ contextKeys.set('git.repository', repository);
+ services.provide(CoreServices.Extensions, extensions);
+ services.provide(CoreServices.ContextKeys, contextKeys);
+ let onFiles: () => void = () => undefined;
+ const project = {
+ fs: {
+ onDidChange: (listener: () => void) => {
+ onFiles = listener;
+ return { dispose: () => undefined };
+ },
+ },
+ };
+ const current = new ObservableValue(undefined);
+ let dismissed: string[] = [];
+ const settings = {
+ get: () => dismissed,
+ set: vi.fn(async (_key: unknown, value: string[]) => {
+ dismissed = value;
+ }),
+ };
+ const notify = vi.fn();
+ const preview = vi.fn(async () => apply);
+ const applyChangeset = vi.fn(async () => true);
+ const warn = vi.fn();
+ const service = new AdoptionService({
+ services,
+ projects: { current } as never,
+ settings: settings as never,
+ workspace: { applyChangeset } as never,
+ previews: { preview } as never,
+ notifications: { notify } as never,
+ logger: { warn } as never,
+ });
+ const check = (id: string, run: () => Promise) =>
+ extensions.contribute(PROJECT_CHECKS, { id, run }, { owner: 'core' });
+ return {
+ service,
+ current,
+ project,
+ check,
+ settings,
+ notify,
+ preview,
+ applyChangeset,
+ warn,
+ files: () => onFiles(),
+ };
+};
+
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+afterEach(() => vi.useRealTimers());
+
+describe('AdoptionService', () => {
+ it('runs every check when a project opens, without the dismissed ones nor failing checks', async () => {
+ const { service, current, project, check, warn } = setup();
+ check('one', async () => [suggestion('a'), suggestion('b')]);
+ check('broken', async () => {
+ throw new Error('boom');
+ });
+ current.set(project);
+ await flush();
+ expect(service.report.get().suggestions.map((item) => item.id)).toEqual(['a', 'b']);
+ expect(warn).toHaveBeenCalledWith('The broken check failed', expect.any(Error));
+
+ await service.dismiss(service.report.get().suggestions[0]!);
+ expect(service.report.get().suggestions.map((item) => item.id)).toEqual(['b']);
+ await service.refresh();
+ expect(service.report.get().suggestions.map((item) => item.id)).toEqual(['b']);
+ current.set(undefined);
+ await flush();
+ expect(service.report.get()).toEqual({ checking: false, suggestions: [] });
+ service.dispose();
+ });
+
+ it('checks again a moment after files change', async () => {
+ const { service, current, project, check, files } = setup();
+ let count = 0;
+ check('count', async () => [suggestion(`run-${++count}`)]);
+ current.set(project);
+ await flush();
+ vi.useFakeTimers();
+ files();
+ files();
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(service.report.get().suggestions.map((item) => item.id)).toEqual(['run-2']);
+ service.dispose();
+ });
+
+ it('applies a fix only after its preview is accepted', async () => {
+ const accepted = setup({ repository: true });
+ expect(await accepted.service.fix(suggestion('a'))).toBe(true);
+ expect(accepted.preview).toHaveBeenCalledWith(
+ expect.objectContaining({ notes: [expect.stringMatching(/git repository/)] }),
+ );
+ expect(accepted.applyChangeset).toHaveBeenCalled();
+
+ const refused = setup({ apply: false });
+ expect(await refused.service.fix(suggestion('a'))).toBe(false);
+ expect(refused.applyChangeset).not.toHaveBeenCalled();
+ });
+
+ it('says when a fix changes nothing, and when it cannot be made', async () => {
+ const { service, notify } = setup();
+ expect(await service.fix(suggestion('empty', []))).toBe(false);
+ expect(notify).toHaveBeenCalledWith('info', 'Fix empty: nothing to change');
+ expect(await service.fix({ ...suggestion('none'), fix: undefined })).toBe(false);
+ const failing = {
+ ...suggestion('bad'),
+ fix: { title: 'Fix bad', changeset: async () => Promise.reject(new Error('no')) },
+ };
+ expect(await service.fix(failing as ProjectSuggestion)).toBe(false);
+ expect(notify).toHaveBeenLastCalledWith('error', 'Fix bad: not possible', expect.anything());
+ });
+});
diff --git a/apps/editor/vite.config.ts b/apps/editor/vite.config.ts
index 089254dc..d341909a 100644
--- a/apps/editor/vite.config.ts
+++ b/apps/editor/vite.config.ts
@@ -76,8 +76,26 @@ const analyzeBundle = (): Plugin => ({
},
});
+/**
+ * The kit playground (`/kit`) is for the dev server: a build gets an empty module in its place.
+ * Its import sits in a branch the build drops, but Rollup still emitted the chunk.
+ */
+const KIT_STUB = '\0kit-playground-stub';
+const noKitPlayground = (): Plugin => ({
+ name: 'nanoforge-no-kit-playground',
+ apply: 'build',
+ enforce: 'pre',
+ resolveId: (id) => (id.endsWith('/kit/KitPlayground.svelte') ? KIT_STUB : undefined),
+ load: (id) => (id === KIT_STUB ? 'export default undefined;' : undefined),
+});
+
export default defineConfig({
- plugins: [sveltekit(), editorServer(), ...(process.env.ANALYZE ? [analyzeBundle()] : [])],
+ plugins: [
+ noKitPlayground(),
+ sveltekit(),
+ editorServer(),
+ ...(process.env.ANALYZE ? [analyzeBundle()] : []),
+ ],
define: { __EDITOR_VERSION__: JSON.stringify(pkg.version) },
worker: { format: 'es' },
server: {
diff --git a/commitlint.config.cjs b/commitlint.config.cjs
index 00b811e2..36762f56 100644
--- a/commitlint.config.cjs
+++ b/commitlint.config.cjs
@@ -1,26 +1,14 @@
-const PACKAGES = [
- 'editor',
- 'kernel',
- 'sdk',
- 'rpc',
- 'history',
- 'settings',
- 'layout',
- 'meta',
- 'ui',
- 'project',
- 'code',
- 'runtime',
- 'server-core',
- 'registry',
-];
-
const { existsSync, readdirSync } = require('node:fs');
const { join } = require('node:path');
-/** Built-in plugins (`plugins/`) are scopes too. */
-const PLUGINS_DIR = join(__dirname, 'plugins');
-const PLUGINS = existsSync(PLUGINS_DIR) ? readdirSync(PLUGINS_DIR) : [];
+/** Every workspace is a scope by its folder name: `apps/editor` → `editor`, `plugins/git` → `git`. */
+const WORKSPACE_GROUPS = ['apps', 'packages', 'plugins', 'tooling'];
+const WORKSPACES = WORKSPACE_GROUPS.flatMap((group) => {
+ const dir = join(__dirname, group);
+ return existsSync(dir)
+ ? readdirSync(dir).filter((name) => existsSync(join(dir, name, 'package.json')))
+ : [];
+});
module.exports = {
extends: ['@commitlint/config-conventional'],
@@ -28,7 +16,7 @@ module.exports = {
'scope-enum': [
1,
'always',
- [...PACKAGES, ...PLUGINS, 'deps', 'ci', 'release', 'docs', 'tooling', 'specs'],
+ [...WORKSPACES, 'deps', 'ci', 'release', 'docs', 'tooling', 'specs'],
],
},
};
diff --git a/coverage-floors.json b/coverage-floors.json
new file mode 100644
index 00000000..64d9bdd6
--- /dev/null
+++ b/coverage-floors.json
@@ -0,0 +1,170 @@
+{
+ "@nanoforge-dev/editor": {
+ "lines": 10,
+ "statements": 10,
+ "functions": 8,
+ "branches": 4
+ },
+ "@nanoforge-dev/editor-code": {
+ "lines": 83,
+ "statements": 79,
+ "functions": 73,
+ "branches": 72
+ },
+ "@nanoforge-dev/editor-history": {
+ "lines": 91,
+ "statements": 88,
+ "functions": 82,
+ "branches": 78
+ },
+ "@nanoforge-dev/editor-kernel": {
+ "lines": 92,
+ "statements": 88,
+ "functions": 85,
+ "branches": 80
+ },
+ "@nanoforge-dev/editor-layout": {
+ "lines": 91,
+ "statements": 88,
+ "functions": 89,
+ "branches": 82
+ },
+ "@nanoforge-dev/editor-meta": {
+ "lines": 76,
+ "statements": 72,
+ "functions": 73,
+ "branches": 61
+ },
+ "@nanoforge-dev/editor-plugin-code-editor": {
+ "lines": 69,
+ "statements": 65,
+ "functions": 65,
+ "branches": 49
+ },
+ "@nanoforge-dev/editor-plugin-command-palette": {
+ "lines": 71,
+ "statements": 72,
+ "functions": 59,
+ "branches": 75
+ },
+ "@nanoforge-dev/editor-plugin-console": {
+ "lines": 66,
+ "statements": 65,
+ "functions": 55,
+ "branches": 74
+ },
+ "@nanoforge-dev/editor-plugin-ecs": {
+ "lines": 62,
+ "statements": 59,
+ "functions": 52,
+ "branches": 54
+ },
+ "@nanoforge-dev/editor-plugin-file-manager": {
+ "lines": 63,
+ "statements": 60,
+ "functions": 52,
+ "branches": 62
+ },
+ "@nanoforge-dev/editor-plugin-git": {
+ "lines": 39,
+ "statements": 37,
+ "functions": 25,
+ "branches": 43
+ },
+ "@nanoforge-dev/editor-plugin-history-panel": {
+ "lines": 99,
+ "statements": 99,
+ "functions": 99,
+ "branches": 99
+ },
+ "@nanoforge-dev/editor-plugin-inspectors": {
+ "lines": 74,
+ "statements": 74,
+ "functions": 66,
+ "branches": 82
+ },
+ "@nanoforge-dev/editor-plugin-packages": {
+ "lines": 13,
+ "statements": 12,
+ "functions": 16,
+ "branches": 23
+ },
+ "@nanoforge-dev/editor-plugin-scene": {
+ "lines": 67,
+ "statements": 64,
+ "functions": 61,
+ "branches": 52
+ },
+ "@nanoforge-dev/editor-plugin-settings-ui": {
+ "lines": 58,
+ "statements": 57,
+ "functions": 55,
+ "branches": 53
+ },
+ "@nanoforge-dev/editor-plugin-viewport": {
+ "lines": 20,
+ "statements": 24,
+ "functions": 32,
+ "branches": 47
+ },
+ "@nanoforge-dev/editor-project": {
+ "lines": 87,
+ "statements": 83,
+ "functions": 72,
+ "branches": 70
+ },
+ "@nanoforge-dev/editor-protocol": {
+ "lines": 18,
+ "statements": 20,
+ "functions": 87,
+ "branches": 92
+ },
+ "@nanoforge-dev/editor-rpc": {
+ "lines": 86,
+ "statements": 81,
+ "functions": 82,
+ "branches": 69
+ },
+ "@nanoforge-dev/editor-runtime": {
+ "lines": 87,
+ "statements": 83,
+ "functions": 71,
+ "branches": 76
+ },
+ "@nanoforge-dev/editor-sdk": {
+ "lines": 0,
+ "statements": 0,
+ "functions": 0,
+ "branches": 99
+ },
+ "@nanoforge-dev/editor-server-core": {
+ "lines": 85,
+ "statements": 82,
+ "functions": 78,
+ "branches": 72
+ },
+ "@nanoforge-dev/editor-settings": {
+ "lines": 76,
+ "statements": 73,
+ "functions": 64,
+ "branches": 70
+ },
+ "@nanoforge-dev/editor-ui": {
+ "lines": 47,
+ "statements": 47,
+ "functions": 43,
+ "branches": 39
+ },
+ "@nanoforge-dev/editor-vite-plugin": {
+ "lines": 83,
+ "statements": 82,
+ "functions": 57,
+ "branches": 62
+ },
+ "@nanoforge-dev/registry": {
+ "lines": 93,
+ "statements": 90,
+ "functions": 89,
+ "branches": 81
+ }
+}
diff --git a/knip.json b/knip.json
new file mode 100644
index 00000000..9032ff09
--- /dev/null
+++ b/knip.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://unpkg.com/knip@6/schema.json",
+ "ignore": [
+ "examples/**",
+ "my-first-game/**",
+ "docs/drafts/**",
+ "**/test/fixtures/**",
+ "apps/editor/tutorial/**",
+ "apps/editor/e2e/.workspace*/**",
+ ".engine/**"
+ ],
+ "ignoreDependencies": ["eslint-formatter-pretty", "@nanoforge-dev/actions"],
+ "ignoreBinaries": ["mkfifo"],
+ "workspaces": {
+ ".": {
+ "entry": ["scripts/*.mjs", "vitest.*.ts", "commitlint.config.cjs"]
+ },
+ "apps/editor": {
+ "entry": [
+ "server/main.ts",
+ "scripts/*.{ts,mjs}",
+ "e2e/**/*.ts",
+ "test/**/*.spec.ts",
+ "e2e/prepare-workspace.mjs"
+ ],
+ "ignoreDependencies": ["@nanoforge-dev/editor-plugin-.+", "chokidar", "unrun"]
+ },
+ "{packages,plugins,tooling}/*": {
+ "entry": [
+ "src/worker/index.ts",
+ "src/server/index.ts",
+ "scripts/*.{ts,mjs}",
+ "test/**/*.spec.ts",
+ "*.mjs"
+ ]
+ },
+ "packages/ui": {
+ "ignoreDependencies": ["@fontsource/.+"]
+ },
+ "tooling/example-plugin": {
+ "ignoreDependencies": ["svelte"]
+ }
+ }
+}
diff --git a/package.json b/package.json
index ca52ce52..a8f08578 100644
--- a/package.json
+++ b/package.json
@@ -23,11 +23,15 @@
"dev": "turbo run dev --filter=@nanoforge-dev/editor --filter='./plugins/*'",
"build": "turbo run build",
"typecheck": "turbo run typecheck",
- "lint": "prettier --check . && eslint --format=pretty scripts commitlint.config.cjs vitest.setup.ts vitest.global-setup.ts && turbo run lint",
+ "lint": "prettier --check . && node scripts/check-workspaces.mjs && knip --no-progress --no-config-hints && eslint --format=pretty scripts commitlint.config.cjs vitest.setup.ts vitest.global-setup.ts && turbo run lint",
"format": "prettier --write . && turbo run format",
"test": "pnpm run test:unit",
"test:unit": "turbo run test:unit",
+ "test:coverage": "turbo run test:coverage",
+ "e2e:last-failure": "pnpm --filter @nanoforge-dev/editor e2e:last-failure",
"example": "node scripts/example.mjs",
+ "setup:check": "node scripts/setup-check.mjs",
+ "setup:engine": "node scripts/setup-engine.mjs",
"prepare": "husky"
},
"devDependencies": {
@@ -43,6 +47,7 @@
"eslint-plugin-svelte": "catalog:lint",
"globals": "catalog:lint",
"husky": "catalog:ci",
+ "knip": "catalog:lint",
"lint-staged": "catalog:ci",
"prettier": "catalog:lint",
"prettier-plugin-svelte": "catalog:lint",
diff --git a/packages/code/package.json b/packages/code/package.json
index 3ead8aab..88399e8f 100644
--- a/packages/code/package.json
+++ b/packages/code/package.json
@@ -38,7 +38,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
- "test:unit": "vitest run --config ../../vitest.config.ts"
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage"
},
"dependencies": {
"@nanoforge-dev/editor-history": "workspace:*",
diff --git a/packages/code/test/service/code-service.spec.ts b/packages/code/test/service/code-service.spec.ts
index 21d012b8..cc629b0e 100644
--- a/packages/code/test/service/code-service.spec.ts
+++ b/packages/code/test/service/code-service.spec.ts
@@ -34,6 +34,19 @@ import { serveEngine } from '../../src/worker-rpc/serve-engine';
const MAIN = 'apps/client/src/main.ts';
+/**
+ * Waits for a check's result. A checker runs TypeScript over the project: with coverage on a slow
+ * runner it takes several times longer than locally, so the wait is long and the tests' timeouts
+ * leave room for it.
+ */
+const settled = (done: () => unknown, timeout = 25_000) =>
+ vi.waitFor(
+ () => {
+ if (!done()) throw new Error('not yet');
+ },
+ { timeout, interval: 25 },
+ );
+
/** Worker plugin: renames the first `const` declared in a file. */
const renamePlugin: WorkerPluginModule = {
activate: (sdk) => {
@@ -229,22 +242,18 @@ describe('CodeService', () => {
await code.engine();
await (await code.engine()).setFiles([{ path: uri, text: await documents.getText(uri) }]);
const watch = code.watchDiagnostics(uri);
- const start = Date.now();
- while (!diagnostics.forUri(uri).length && Date.now() - start < 8000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => diagnostics.forUri(uri).length);
expect(diagnostics.forUri(uri).map((d) => d.code)).toEqual([2322]);
watch.dispose();
expect(diagnostics.forUri(uri)).toEqual([]);
code.dispose();
- });
+ }, 60_000);
it('checks every file of the project while asked to, read-only packages aside', async () => {
const { code, diagnostics } = setup();
const uri = 'checks/broken.ts';
const check = code.checkProject();
- const start = Date.now();
- while (!diagnostics.forUri(uri).length && Date.now() - start < 15_000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => diagnostics.forUri(uri).length);
expect(diagnostics.forUri(uri)).toMatchObject([
{ code: 2322, source: 'typescript', line: 1, column: 14 },
]);
@@ -254,7 +263,7 @@ describe('CodeService', () => {
check.dispose();
expect(diagnostics.forUri(uri)).toEqual([]);
code.dispose();
- });
+ }, 60_000);
it('still checks the whole project when an edit comes before the first check ends', async () => {
const { code, diagnostics } = setup();
@@ -265,42 +274,34 @@ describe('CodeService', () => {
return [];
});
const check = code.checkProject();
- const start = Date.now();
- while (!diagnostics.forUri('checks/broken.ts').length && Date.now() - start < 15_000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => diagnostics.forUri('checks/broken.ts').length);
expect(diagnostics.forUri('checks/broken.ts').map((d) => d.code)).toEqual([2322]);
await project.fs.write('checks/speed.ts', 'export const speed = 1;\n');
check.dispose();
code.dispose();
- }, 20_000);
+ }, 60_000);
it('checks everything again after an edit to global declarations', async () => {
const { code, diagnostics } = setup();
const check = code.checkProject();
- const start = Date.now();
- while (!diagnostics.forUri('checks/broken.ts').length && Date.now() - start < 15_000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => diagnostics.forUri('checks/broken.ts').length);
const refresh = vi.spyOn(code, 'refreshDiagnostics');
await project.fs.write(
'checks/speed.ts',
'export const speed = 1;\ndeclare global {\n var debugSpeed: number;\n}\n',
);
- const edited = Date.now();
const checked = () => new Set(refresh.mock.calls.flatMap(([paths]) => paths));
- while (!checked().has('checks/broken.ts') && Date.now() - edited < 10_000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => checked().has('checks/broken.ts'));
expect(checked().has('checks/broken.ts')).toBe(true);
await project.fs.write('checks/speed.ts', 'export const speed = 1;\n');
check.dispose();
code.dispose();
- }, 30_000);
+ }, 60_000);
it('keeps an edit made while a check reads what changed', async () => {
const { code, diagnostics } = setup();
const check = code.checkProject();
- const start = Date.now();
- while (!diagnostics.forUri('checks/broken.ts').length && Date.now() - start < 15_000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => diagnostics.forUri('checks/broken.ts').length);
const refresh = vi.spyOn(code, 'refreshDiagnostics');
/** While the check of `broken.ts` reads it, `speed.ts` changes: what imports it is checked. */
const service = code as unknown as {
@@ -313,35 +314,29 @@ describe('CodeService', () => {
});
await project.fs.write('checks/broken.ts', 'export const n: number =\n "other";\n');
const user = 'checks/uses-speed.ts';
- const edited = Date.now();
const checked = () => new Set(refresh.mock.calls.flatMap(([paths]) => paths));
- while (!checked().has(user) && Date.now() - edited < 10_000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => checked().has(user));
expect(checked().has('checks/broken.ts')).toBe(true);
expect(checked().has(user)).toBe(true);
await project.fs.write('checks/broken.ts', 'export const n: number =\n "text";\n');
await project.fs.write('checks/speed.ts', 'export const speed = 1;\n');
check.dispose();
code.dispose();
- }, 30_000);
+ }, 60_000);
it('after an edit, checks again only the file and what imports it', async () => {
const { code, diagnostics } = setup();
const check = code.checkProject();
- const start = Date.now();
- while (!diagnostics.forUri('checks/broken.ts').length && Date.now() - start < 15_000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => diagnostics.forUri('checks/broken.ts').length);
const refresh = vi.spyOn(code, 'refreshDiagnostics');
await project.fs.write('checks/speed.ts', 'export const speed = "fast";\n');
const user = 'checks/uses-speed.ts';
- const edited = Date.now();
- while (!diagnostics.forUri(user).length && Date.now() - edited < 10_000)
- await new Promise((r) => setTimeout(r, 25));
+ await settled(() => diagnostics.forUri(user).length);
expect(diagnostics.forUri(user).map((d) => d.code)).toEqual([2322]);
const checked = new Set(refresh.mock.calls.flatMap(([paths]) => paths));
expect([...checked].sort()).toEqual(['checks/speed.ts', user]);
await project.fs.write('checks/speed.ts', 'export const speed = 1;\n');
check.dispose();
code.dispose();
- });
+ }, 60_000);
});
diff --git a/packages/history/package.json b/packages/history/package.json
index 61766ca8..c93817d2 100644
--- a/packages/history/package.json
+++ b/packages/history/package.json
@@ -36,7 +36,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
- "test:unit": "vitest run --config ../../vitest.config.ts"
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage"
},
"dependencies": {
"@nanoforge-dev/editor-kernel": "workspace:*"
diff --git a/packages/kernel/package.json b/packages/kernel/package.json
index a92f6e0f..7385d53f 100644
--- a/packages/kernel/package.json
+++ b/packages/kernel/package.json
@@ -37,6 +37,7 @@
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"generate:schema": "bun ./scripts/generate-schema.ts"
},
"dependencies": {
diff --git a/packages/kernel/src/plugin/manifest-fields.ts b/packages/kernel/src/plugin/manifest-fields.ts
index f02b6c97..652595ed 100644
--- a/packages/kernel/src/plugin/manifest-fields.ts
+++ b/packages/kernel/src/plugin/manifest-fields.ts
@@ -2,7 +2,7 @@ import semver from 'semver';
import { z } from 'zod';
/** Registry package name: `@scope/name`, like a scoped npm package. */
-export const PLUGIN_NAME_PATTERN = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
+const PLUGIN_NAME_PATTERN = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
export const PluginName = z
.string()
@@ -26,7 +26,7 @@ export const PluginDependencies = z
export const NpmRanges = z.record(z.string().min(1), Range).default({});
-export const ACTIVATION_EVENT_PATTERN =
+const ACTIVATION_EVENT_PATTERN =
/^(onStartup|on(Command|Screen|Widget|ProjectType|Language|Uri):.+)$/;
export const ActivationEvent = z
diff --git a/packages/kernel/turbo.json b/packages/kernel/turbo.json
index f549607d..04978fee 100644
--- a/packages/kernel/turbo.json
+++ b/packages/kernel/turbo.json
@@ -11,6 +11,16 @@
"../ui/src/theme/theme-contribution.ts",
"../ui/src/theme/tokens.ts"
]
+ },
+ "test:coverage": {
+ "inputs": [
+ "$TURBO_EXTENDS$",
+ "scripts/**",
+ "schemas/**",
+ "../ui/src/theme/icon-theme.schema.ts",
+ "../ui/src/theme/theme-contribution.ts",
+ "../ui/src/theme/tokens.ts"
+ ]
}
}
}
diff --git a/packages/layout/package.json b/packages/layout/package.json
index aa558df5..39592401 100644
--- a/packages/layout/package.json
+++ b/packages/layout/package.json
@@ -37,6 +37,7 @@
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"bench": "vitest bench --run bench"
},
"dependencies": {
diff --git a/packages/layout/src/serialization/layout.schema.ts b/packages/layout/src/serialization/layout.schema.ts
index f01f2dc3..33293d4e 100644
--- a/packages/layout/src/serialization/layout.schema.ts
+++ b/packages/layout/src/serialization/layout.schema.ts
@@ -1,7 +1,7 @@
import { z } from 'zod';
-export const Ref = z.object({ instanceId: z.string().min(1), widgetId: z.string().min(1) });
-export const Stack = z.object({
+const Ref = z.object({ instanceId: z.string().min(1), widgetId: z.string().min(1) });
+const Stack = z.object({
tabs: z.array(Ref).catch([]),
active: z.string().nullable().catch(null),
});
diff --git a/packages/meta/package.json b/packages/meta/package.json
index e57ec635..8db8c4bb 100644
--- a/packages/meta/package.json
+++ b/packages/meta/package.json
@@ -37,7 +37,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
- "test:unit": "vitest run --config ../../vitest.config.ts"
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage"
},
"dependencies": {
"ts-morph": "catalog:code",
diff --git a/packages/project/package.json b/packages/project/package.json
index a95bdd2f..5007b85c 100644
--- a/packages/project/package.json
+++ b/packages/project/package.json
@@ -36,7 +36,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
- "test:unit": "vitest run --config vitest.config.ts"
+ "test:unit": "vitest run --config vitest.config.ts",
+ "test:coverage": "BROWSERS=chromium vitest run --config vitest.config.ts --coverage"
},
"dependencies": {
"@nanoforge-dev/editor-kernel": "workspace:*",
diff --git a/packages/project/src/cache/cache-db.ts b/packages/project/src/cache/cache-db.ts
index 7dc5ec08..f547b6d0 100644
--- a/packages/project/src/cache/cache-db.ts
+++ b/packages/project/src/cache/cache-db.ts
@@ -2,7 +2,7 @@
* The editor's IndexedDB database of cached projects. Every store is created here, with one
* version: opening it elsewhere at another version would fail (`VersionError`).
*/
-export const CACHE_DB = 'nanoforge-editor';
+const CACHE_DB = 'nanoforge-editor';
const VERSION = 2;
/** `user\0projectId` → the project's file tree. */
@@ -33,7 +33,7 @@ export const blobKey = (user: string, hash: string) => `${user}\0${hash}`;
export const isCacheDbSupported = (): boolean => typeof indexedDB !== 'undefined';
/** Opens the database; version 1 (trees by project id only) is dropped on upgrade. */
-export const openCacheDb = (): Promise =>
+const openCacheDb = (): Promise =>
new Promise((resolve, reject) => {
const request = indexedDB.open(CACHE_DB, VERSION);
request.onupgradeneeded = (event) => {
diff --git a/packages/project/src/workspace/library-operations.ts b/packages/project/src/workspace/library-operations.ts
index 4a932eee..121b8f9c 100644
--- a/packages/project/src/workspace/library-operations.ts
+++ b/packages/project/src/workspace/library-operations.ts
@@ -59,10 +59,10 @@ export const setLibraryUse = (
return changes.run(() => setUse(io, changes, app, library, used));
};
-export const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** A module specifier of the library in an import, an export or a dynamic import. */
-export const specifier = (library: string) =>
+const specifier = (library: string) =>
new RegExp(
`(\\bfrom\\s*|\\bimport\\s*\\(?\\s*|\\brequire\\s*\\(\\s*)(["'])${escape(library)}(/[^"']*)?\\2`,
'g',
diff --git a/packages/project/vitest.config.ts b/packages/project/vitest.config.ts
index 2e099572..7187e77c 100644
--- a/packages/project/vitest.config.ts
+++ b/packages/project/vitest.config.ts
@@ -2,6 +2,8 @@ import { playwright } from '@vitest/browser-playwright';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
+import { coverage } from '../../vitest.coverage.ts';
+
const root = (path: string) => fileURLToPath(new URL(`../../${path}`, import.meta.url));
/**
@@ -12,6 +14,8 @@ const root = (path: string) => fileURLToPath(new URL(`../../${path}`, import.met
export default defineConfig({
test: {
passWithNoTests: true,
+ testTimeout: 20_000,
+ coverage: coverage(),
setupFiles: [root('vitest.setup.ts')],
projects: [
{
diff --git a/packages/protocol/package.json b/packages/protocol/package.json
index b13638ad..b1f3f725 100644
--- a/packages/protocol/package.json
+++ b/packages/protocol/package.json
@@ -36,7 +36,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
- "test:unit": "vitest run --config ../../vitest.config.ts"
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage"
},
"dependencies": {
"@nanoforge-dev/editor-rpc": "workspace:*",
diff --git a/packages/registry/package.json b/packages/registry/package.json
index 462295f7..2b475036 100644
--- a/packages/registry/package.json
+++ b/packages/registry/package.json
@@ -39,6 +39,7 @@
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"test:contract": "vitest run --config ../../vitest.config.ts test/contract"
},
"dependencies": {
diff --git a/packages/rpc/package.json b/packages/rpc/package.json
index 34466cb8..48fa148e 100644
--- a/packages/rpc/package.json
+++ b/packages/rpc/package.json
@@ -36,7 +36,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
- "test:unit": "vitest run --config ../../vitest.config.ts"
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage"
},
"dependencies": {
"@nanoforge-dev/editor-kernel": "workspace:*",
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
index 84d360c0..2127fb18 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -36,7 +36,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
- "test:unit": "vitest run --config ../../vitest.config.ts"
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage"
},
"dependencies": {
"@jridgewell/trace-mapping": "catalog:libs",
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 51a71473..76438cb8 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -43,6 +43,7 @@
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
"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 'packages/sdk/*'",
"release": "cliff-jumper"
},
diff --git a/packages/server-core/package.json b/packages/server-core/package.json
index cac1cb8f..4120784e 100644
--- a/packages/server-core/package.json
+++ b/packages/server-core/package.json
@@ -38,7 +38,9 @@
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
- "test:contract": "vitest run --config ../../vitest.config.ts test/contract"
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
+ "test:contract": "vitest run --config ../../vitest.config.ts test/contract",
+ "generate:env": "bun ./scripts/generate-env-example.ts"
},
"dependencies": {
"@nanoforge-dev/config": "catalog:config",
@@ -50,7 +52,6 @@
"fflate": "catalog:libs",
"ignore": "catalog:libs",
"prettier": "catalog:server",
- "semver": "catalog:libs",
"typescript": "catalog:build",
"unrun": "catalog:build",
"yaml": "catalog:server",
@@ -59,8 +60,6 @@
"devDependencies": {
"@types/bun": "catalog:core",
"@types/node": "catalog:core",
- "@types/semver": "catalog:libs",
- "typescript": "catalog:build",
"vitest": "catalog:test"
},
"packageManager": "pnpm@12.0.0",
diff --git a/packages/server-core/scripts/env-example.ts b/packages/server-core/scripts/env-example.ts
new file mode 100644
index 00000000..691af4c4
--- /dev/null
+++ b/packages/server-core/scripts/env-example.ts
@@ -0,0 +1,44 @@
+import { readFileSync } from 'node:fs';
+import { z } from 'zod';
+
+import { EnvSchema } from '../src/env/env.schema';
+
+const SCHEMA_FILE = new URL('../src/env/env.schema.ts', import.meta.url);
+
+/** The doc comment above each key of the schema's object, as plain lines. */
+const docs = (source: string): Map => {
+ const found = new Map();
+ for (const match of source.matchAll(/\/\*\*((?:(?!\*\/)[\s\S])*)\*\/\s*\n\s*(\w+):/g)) {
+ const lines = match[1]!
+ .split('\n')
+ .map((line) => line.replace(/^\s*\*\s?/, '').trim())
+ .filter(Boolean);
+ found.set(match[2]!, lines);
+ }
+ return found;
+};
+
+/**
+ * `.env.example` at the repository root: every variable of `EnvSchema`, with its doc comment
+ * and its default, commented out. `pnpm generate:env` writes it; a test keeps it current.
+ */
+export const envExample = (): string => {
+ const schema = z.toJSONSchema(EnvSchema, { io: 'input', unrepresentable: 'any' }) as {
+ properties: Record;
+ };
+ const comments = docs(readFileSync(SCHEMA_FILE, 'utf8'));
+ const blocks = Object.entries(schema.properties).map(([key, property]) => {
+ const value = property.default === undefined ? '' : String(property.default);
+ const lines = (comments.get(key) ?? []).map((line) => `# ${line.replace(/`/g, '')}`);
+ return [...lines, `# ${key}=${value}`].join('\n');
+ });
+ return [
+ '# 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.',
+ '',
+ blocks.join('\n\n'),
+ '',
+ ].join('\n');
+};
diff --git a/packages/server-core/scripts/generate-env-example.ts b/packages/server-core/scripts/generate-env-example.ts
new file mode 100644
index 00000000..1deeccd1
--- /dev/null
+++ b/packages/server-core/scripts/generate-env-example.ts
@@ -0,0 +1,7 @@
+import { writeFileSync } from 'node:fs';
+
+import { envExample } from './env-example';
+
+const file = new URL('../../../.env.example', import.meta.url);
+writeFileSync(file, envExample());
+console.log(`Wrote ${file.pathname}`);
diff --git a/packages/server-core/src/env/env.schema.ts b/packages/server-core/src/env/env.schema.ts
index 3e9a6d8b..046970f7 100644
--- a/packages/server-core/src/env/env.schema.ts
+++ b/packages/server-core/src/env/env.schema.ts
@@ -33,16 +33,24 @@ export type ApiFeature = (typeof API_FEATURES)[number];
/** Environment of the editor server, validated at startup. */
export const EnvSchema = z
.object({
+ /** `production`, `development` (more logs, dev tooling) or `test`. */
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
/** OFFLINE: local projects on disk. ONLINE: hosted, projects synced with git gateways. */
PUBLIC_MODE: z.enum(['OFFLINE', 'ONLINE']).default('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: optionalString,
+ /** Port the server listens on (0: any free port). */
PORT: z.coerce.number().int().min(0).max(65_535).default(3000),
/** Projects must live under this directory (defaults to the working directory). */
FS_ROOT: optionalString,
- /** Editor data: recent projects, installed plugins… */
+ /** Editor data: recent projects, installed plugins… (defaults to `~/.nanoforge/editor`). */
DATA_DIR: optionalString,
+ /** The NanoForge API. */
API_URL: z.string().url().default('https://api.nanoforge.eu'),
+ /** The editor's key for the NanoForge API: required in ONLINE mode. */
API_KEY: optionalString,
/** The API features that are deployed, comma separated (`settings,registry`). None by default. */
API_FEATURES: list,
@@ -52,8 +60,11 @@ export const EnvSchema = z
REGISTRY_DIR: optionalString,
/** NanoForge projects website, where ONLINE users sign in. */
PUBLIC_PM_URL: z.string().url().default('https://projects.nanoforge.eu'),
+ /** Signs the session cookies. Empty: a random one at each start (sessions end on restart). */
SESSION_SECRET: optionalString,
+ /** The NanoForge CLI, when `nf` is not in the PATH (a project's own CLI is tried first). */
NF_CLI_PATH: optionalString,
+ /** Git, when `git` is not in the PATH. */
GIT_PATH: optionalString,
/** Extra origins allowed to call the server (dev proxies). */
ALLOWED_ORIGINS: list,
@@ -66,6 +77,7 @@ export const EnvSchema = z
DEV_PLUGINS: list,
/** Directory of the plugins bundled with the editor build. */
BUNDLED_PLUGINS_DIR: optionalString,
+ /** Files of the tutorial's project (_Help › Tutorial_), when not next to the editor build. */
TUTORIAL_DIR: optionalString,
/**
* Lets a local (OFFLINE) editor listen on a non-loopback `HOST`. It has no sign-in and runs
diff --git a/packages/server-core/src/git/parse-status.ts b/packages/server-core/src/git/parse-status.ts
index a2d6feb7..be735a6e 100644
--- a/packages/server-core/src/git/parse-status.ts
+++ b/packages/server-core/src/git/parse-status.ts
@@ -1,6 +1,6 @@
import type { GitChange, GitFile, GitStatus } from '@nanoforge-dev/editor-protocol';
-export const CHANGES: Record = {
+const CHANGES: Record = {
M: 'M',
T: 'M',
A: 'A',
diff --git a/packages/server-core/src/http/compressed-files.ts b/packages/server-core/src/http/compressed-files.ts
index 6fdfa2f8..381eccb8 100644
--- a/packages/server-core/src/http/compressed-files.ts
+++ b/packages/server-core/src/http/compressed-files.ts
@@ -38,7 +38,7 @@ export const isCompressible = (contentType: string, size: number): boolean =>
* Compresses files once per version (path, size and modification time) and keeps the result:
* the editor's bundles and plugins are served many times and never change while it runs.
*/
-export class CompressedFiles {
+class CompressedFiles {
private readonly _cache = new Map>();
private readonly _sizes = new Map();
private _bytes = 0;
diff --git a/packages/server-core/src/http/static-files.ts b/packages/server-core/src/http/static-files.ts
index ec3273f4..a30cb7a2 100644
--- a/packages/server-core/src/http/static-files.ts
+++ b/packages/server-core/src/http/static-files.ts
@@ -22,7 +22,7 @@ const CONTENT_TYPES: Record = {
'.woff2': 'font/woff2',
};
-export const contentType = (file: string) =>
+const contentType = (file: string) =>
CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream';
/**
diff --git a/packages/server-core/test/env/env-example.spec.ts b/packages/server-core/test/env/env-example.spec.ts
new file mode 100644
index 00000000..27aae4be
--- /dev/null
+++ b/packages/server-core/test/env/env-example.spec.ts
@@ -0,0 +1,11 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+
+import { envExample } from '../../scripts/env-example';
+
+describe('.env.example', () => {
+ it('is up to date (run `pnpm generate:env` in packages/server-core)', () => {
+ const file = new URL('../../../../.env.example', import.meta.url);
+ expect(readFileSync(file, 'utf8')).toBe(envExample());
+ });
+});
diff --git a/packages/server-core/turbo.json b/packages/server-core/turbo.json
new file mode 100644
index 00000000..942daf09
--- /dev/null
+++ b/packages/server-core/turbo.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "test:unit": {
+ "inputs": ["$TURBO_EXTENDS$", "scripts/**", "../../.env.example"]
+ },
+ "test:coverage": {
+ "inputs": ["$TURBO_EXTENDS$", "scripts/**", "../../.env.example"]
+ }
+ }
+}
diff --git a/packages/settings/package.json b/packages/settings/package.json
index 948d77c9..34f1d6f6 100644
--- a/packages/settings/package.json
+++ b/packages/settings/package.json
@@ -36,7 +36,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
- "test:unit": "vitest run --config ../../vitest.config.ts"
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage"
},
"dependencies": {
"@nanoforge-dev/editor-kernel": "workspace:*",
diff --git a/packages/settings/src/account-sync/account-sync-store.ts b/packages/settings/src/account-sync/account-sync-store.ts
index 80ee9d3b..d3cfc4a7 100644
--- a/packages/settings/src/account-sync/account-sync-store.ts
+++ b/packages/settings/src/account-sync/account-sync-store.ts
@@ -108,6 +108,8 @@ export class AccountSyncStore implements ScopeStore {
this._edits++;
this._state = { ...this._state, local: applyPatch(this._state.local, patch), dirty: true };
this._values.set(this._state.local);
+ /** Waiting for its push from now on: the debounce is part of the wait. */
+ if (this._status.get() !== 'syncing') this._status.set('pending');
await this._persist();
this._schedule(this._options.debounceMs ?? 500);
}
diff --git a/packages/settings/src/service/settings-service.ts b/packages/settings/src/service/settings-service.ts
index 84b576e6..337df535 100644
--- a/packages/settings/src/service/settings-service.ts
+++ b/packages/settings/src/service/settings-service.ts
@@ -5,6 +5,7 @@ import {
type Event,
type Logger,
type Observable,
+ ObservableValue,
createToken,
toDisposable,
} from '@nanoforge-dev/editor-kernel';
@@ -30,6 +31,7 @@ export class SettingsService implements Disposable {
private readonly _observables = new Map>();
private readonly _warned = new Set();
private readonly _store = new DisposableStore();
+ private readonly _pending = new ObservableValue(0);
readonly onDidChange: Event = this._onDidChange.event;
@@ -149,19 +151,23 @@ export class SettingsService implements Disposable {
}
if (definition.deprecated)
this._logger?.warn(`"${definition.key}" is deprecated: ${definition.deprecated}`);
- await this._storeOf(scope).write({
- ...this._aliasRemoval(definition),
- [definition.key]: parsed.data,
- });
+ await this._track(
+ this._storeOf(scope).write({
+ ...this._aliasRemoval(definition),
+ [definition.key]: parsed.data,
+ }),
+ );
}
/** Removes the value of a scope (falls back to lower scopes). */
async reset(key: KeyOrDefinition, scope: WritableScope): Promise {
const definition = this._definition(key);
- await this._storeOf(scope).write({
- ...this._aliasRemoval(definition),
- [definition.key]: undefined,
- });
+ await this._track(
+ this._storeOf(scope).write({
+ ...this._aliasRemoval(definition),
+ [definition.key]: undefined,
+ }),
+ );
}
/** The raw values of a scope as formatted JSON. */
@@ -218,7 +224,26 @@ export class SettingsService implements Disposable {
}
async applyImport(preview: ImportPreview): Promise {
- await this._storeOf(preview.scope).write(preview.patch);
+ await this._track(this._storeOf(preview.scope).write(preview.patch));
+ }
+
+ /**
+ * Saves waiting or under way: writes, and what callers {@link hold} while they wait to write
+ * (debounced layout saves). The e2e tests wait for none instead of sleeping.
+ */
+ get pending(): Observable {
+ return this._pending.readonly();
+ }
+
+ /** Counts a save that will come (a debounce running): call the result once it is written. */
+ hold(): () => void {
+ this._pending.set(this._pending.get() + 1);
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ this._pending.set(this._pending.get() - 1);
+ };
}
dispose(): void {
@@ -226,6 +251,15 @@ export class SettingsService implements Disposable {
this._onDidChange.dispose();
}
+ private async _track(write: Promise): Promise {
+ const release = this.hold();
+ try {
+ return await write;
+ } finally {
+ release();
+ }
+ }
+
private _definition(key: KeyOrDefinition): SettingDefinition {
if (typeof key !== 'string') return key;
const definition = this.registry.get(key);
diff --git a/packages/settings/test/account-sync/account-sync-store.spec.ts b/packages/settings/test/account-sync/account-sync-store.spec.ts
index a6695db9..b30a85e1 100644
--- a/packages/settings/test/account-sync/account-sync-store.spec.ts
+++ b/packages/settings/test/account-sync/account-sync-store.spec.ts
@@ -78,6 +78,24 @@ describe('AccountSyncStore', () => {
expect(store.status.get()).toBe('idle');
});
+ it('is pending from the write until its push, debounce included', async () => {
+ const remote = new FakeRemote();
+ const store = new AccountSyncStore(remote, new MemoryKeyValueStore(), 'account:local', {
+ debounceMs: 50,
+ });
+ await store.initialize();
+ expect(store.status.get()).toBe('idle');
+ const write = store.write({ 'editor.fontSize': 14 });
+ expect(store.status.get()).toBe('pending');
+ await write;
+ expect(store.status.get()).toBe('pending');
+ await new Promise((resolve) => setTimeout(resolve, 80));
+ await flush();
+ expect(store.status.get()).toBe('idle');
+ expect(remote.document.values).toEqual({ 'editor.fontSize': 14 });
+ store.dispose();
+ });
+
it('keeps offline edits (persisted) and flushes them on reconnect', async () => {
const remote = new FakeRemote();
const kv = new MemoryKeyValueStore();
diff --git a/packages/settings/test/service/settings-service.spec.ts b/packages/settings/test/service/settings-service.spec.ts
index 1b508702..0758b014 100644
--- a/packages/settings/test/service/settings-service.spec.ts
+++ b/packages/settings/test/service/settings-service.spec.ts
@@ -39,6 +39,24 @@ describe('SettingsService', () => {
expect(service.get(fontSize)).toBe(12);
});
+ it('counts the saves under way and those held until a debounce writes', async () => {
+ const { service, stores } = await setup();
+ let finish: () => void = () => undefined;
+ vi.spyOn(stores.account, 'write').mockImplementation(
+ () => new Promise((resolve) => (finish = resolve)),
+ );
+ const write = service.set(fontSize, 16, 'account');
+ expect(service.pending.get()).toBe(1);
+ const release = service.hold();
+ expect(service.pending.get()).toBe(2);
+ finish();
+ await write;
+ expect(service.pending.get()).toBe(1);
+ release();
+ release();
+ expect(service.pending.get()).toBe(0);
+ });
+
it('applies scope precedence', async () => {
const { service } = await setup();
expect(service.get(fontSize)).toBe(13);
diff --git a/packages/ui/package.json b/packages/ui/package.json
index f027975c..982f4b95 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -42,7 +42,8 @@
"lint": "prettier --check . && eslint --format=pretty .",
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
- "test:unit": "vitest run --config vitest.config.ts"
+ "test:unit": "vitest run --config vitest.config.ts",
+ "test:coverage": "vitest run --config vitest.config.ts --coverage"
},
"dependencies": {
"@fontsource/instrument-sans": "catalog:components",
diff --git a/packages/ui/src/workbench/layout/layout-controller.ts b/packages/ui/src/workbench/layout/layout-controller.ts
index f174fd57..901be9e5 100644
--- a/packages/ui/src/workbench/layout/layout-controller.ts
+++ b/packages/ui/src/workbench/layout/layout-controller.ts
@@ -53,6 +53,8 @@ export class LayoutController implements Disposable {
private readonly _store = new DisposableStore();
private readonly _history: HistoryContext;
private _saveTimer: ReturnType | undefined;
+ /** Counts the coming save in the settings' pending saves. */
+ private _saveHold: (() => void) | undefined;
/** No layout was saved yet: the preset's default was used (offer the other one). */
firstRun = false;
private _floatCounter = 0;
@@ -248,7 +250,7 @@ export class LayoutController implements Disposable {
dispose(): void {
if (this._saveTimer) {
clearTimeout(this._saveTimer);
- void this._persist(this._activeName());
+ void this._persist(this._activeName()).finally(this._saveHold);
}
this._store.dispose();
}
@@ -265,11 +267,14 @@ export class LayoutController implements Disposable {
this._layout.set(layout);
if (options.save === false) return;
clearTimeout(this._saveTimer);
+ this._saveHold ??= this._options.settings.hold();
this._saveTimer = setTimeout(() => {
this._saveTimer = undefined;
- this._persist(this._activeName()).catch((error: unknown) =>
- this._options.logger?.error('Saving the layout failed', error),
- );
+ const release = this._saveHold;
+ this._saveHold = undefined;
+ this._persist(this._activeName())
+ .catch((error: unknown) => this._options.logger?.error('Saving the layout failed', error))
+ .finally(release);
}, SAVE_DELAY_MS);
}
diff --git a/packages/ui/src/workbench/service/workbench-service.ts b/packages/ui/src/workbench/service/workbench-service.ts
index 228d70a7..955192d1 100644
--- a/packages/ui/src/workbench/service/workbench-service.ts
+++ b/packages/ui/src/workbench/service/workbench-service.ts
@@ -65,6 +65,8 @@ export class WorkbenchService implements Disposable {
private readonly _styleRefs = new Map();
private readonly _state: ObservableValue>;
private _stateTimer: ReturnType | undefined;
+ /** Counts the coming save in the settings' pending saves. */
+ private _stateHold: (() => void) | undefined;
private readonly _onDidFail = new Emitter();
constructor(private readonly _options: WorkbenchServiceOptions) {
@@ -236,7 +238,7 @@ export class WorkbenchService implements Disposable {
dispose(): void {
if (this._stateTimer) {
clearTimeout(this._stateTimer);
- void this._saveState();
+ void this._saveState().finally(this._stateHold);
}
for (const record of this._instances.values()) record.store.dispose();
this._instances.clear();
@@ -282,9 +284,12 @@ export class WorkbenchService implements Disposable {
private _scheduleStateSave(): void {
clearTimeout(this._stateTimer);
+ this._stateHold ??= this._options.settings.hold();
this._stateTimer = setTimeout(() => {
this._stateTimer = undefined;
- void this._saveState();
+ const release = this._stateHold;
+ this._stateHold = undefined;
+ void this._saveState().finally(release);
}, 1000);
}
diff --git a/packages/ui/test/workbench/layout/layout-controller.spec.ts b/packages/ui/test/workbench/layout/layout-controller.spec.ts
index 8fcc66e2..b824d568 100644
--- a/packages/ui/test/workbench/layout/layout-controller.spec.ts
+++ b/packages/ui/test/workbench/layout/layout-controller.spec.ts
@@ -9,6 +9,7 @@ const setup = () => {
const settings = {
get: () => ({}),
set: async () => undefined,
+ hold: () => () => undefined,
hasStore: () => false,
observe: () => ({ get: () => ({}), subscribe: () => () => undefined }),
};
diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts
index 69b9176b..cf462d2d 100644
--- a/packages/ui/vitest.config.ts
+++ b/packages/ui/vitest.config.ts
@@ -3,6 +3,8 @@ import { playwright } from '@vitest/browser-playwright';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
+import { coverage } from '../../vitest.coverage.ts';
+
/**
* Node tests (`*.spec.ts`) and real browser tests (`*.svelte.spec.ts`, Chromium through
* Playwright). Set CHROME to use a locally installed browser instead of Playwright's.
@@ -11,6 +13,8 @@ export default defineConfig({
plugins: [svelte({ compilerOptions: { runes: true } })],
test: {
passWithNoTests: true,
+ testTimeout: 20_000,
+ coverage: coverage({ svelte: true }),
setupFiles: [fileURLToPath(new URL('../../vitest.setup.ts', import.meta.url))],
projects: [
{
diff --git a/plugins/code-editor/package.json b/plugins/code-editor/package.json
index e93e1253..0df33fed 100644
--- a/plugins/code-editor/package.json
+++ b/plugins/code-editor/package.json
@@ -35,6 +35,7 @@
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"dev": "vite build --watch",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/code-editor/src/document/unsaved-store.ts b/plugins/code-editor/src/document/unsaved-store.ts
index 5e226c20..6d92de4b 100644
--- a/plugins/code-editor/src/document/unsaved-store.ts
+++ b/plugins/code-editor/src/document/unsaved-store.ts
@@ -1,5 +1,5 @@
/** Unsaved text of a file, and the disk version it was based on. */
-export interface UnsavedText {
+interface UnsavedText {
readonly text: string;
/** Hash of the file on disk when editing started (detects disk changes meanwhile). */
readonly baseHash: string | null;
diff --git a/plugins/code-editor/src/monaco/dispose-diff-editor.ts b/plugins/code-editor/src/monaco/dispose-diff-editor.ts
new file mode 100644
index 00000000..8082cf95
--- /dev/null
+++ b/plugins/code-editor/src/monaco/dispose-diff-editor.ts
@@ -0,0 +1,18 @@
+import type { editor } from 'monaco-editor';
+
+import type { Monaco } from './monaco';
+
+/**
+ * Disposes a diff editor, leaving the other editors working.
+ *
+ * Its models are detached first: a diff still being computed would otherwise make widgets
+ * through its disposed services. Then a throwaway editor is made: every editor sets Monaco's
+ * global hover delegate factory to its own services, and the diff editor's inner editors use a
+ * child of them, disposed with it. Without that, the next hover or suggestion widget of any
+ * editor throws "InstantiationService has been disposed".
+ */
+export const disposeDiffEditor = (monaco: Monaco, diff: editor.IStandaloneDiffEditor): void => {
+ diff.setModel(null);
+ diff.dispose();
+ monaco.editor.create(window.document.createElement('div'), { model: null }).dispose();
+};
diff --git a/plugins/code-editor/src/service/code-editor-service.ts b/plugins/code-editor/src/service/code-editor-service.ts
index f5231b82..492003a7 100644
--- a/plugins/code-editor/src/service/code-editor-service.ts
+++ b/plugins/code-editor/src/service/code-editor-service.ts
@@ -25,8 +25,8 @@ import type {
import { minimalEdit, toMarker } from './editor-edits';
import { pairRenames } from './pair-renames';
-export const SCRIPT_LIKE = /\.[cm]?[jt]sx?$/;
-export const EMPTY: EditorLayout = { groups: [{ tabs: [], active: undefined }], focused: 0 };
+const SCRIPT_LIKE = /\.[cm]?[jt]sx?$/;
+const EMPTY: EditorLayout = { groups: [{ tabs: [], active: undefined }], focused: 0 };
/**
* State of the Script screen for the open project: open documents, editor groups and tabs,
diff --git a/plugins/code-editor/src/typing/type-libraries.ts b/plugins/code-editor/src/typing/type-libraries.ts
index 0853404c..0eb2d06a 100644
--- a/plugins/code-editor/src/typing/type-libraries.ts
+++ b/plugins/code-editor/src/typing/type-libraries.ts
@@ -8,13 +8,13 @@ const BARE_IMPORT =
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"./][^'"]*)['"]/g;
/** Package name of a bare import (`@scope/name/sub` → `@scope/name`). */
-export const packageOf = (specifier: string): string => {
+const packageOf = (specifier: string): string => {
const parts = specifier.split('/');
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!;
};
/** Bare imports of a source text. */
-export const bareImports = (text: string): string[] =>
+const bareImports = (text: string): string[] =>
[...new Set([...text.matchAll(BARE_IMPORT)].map((match) => packageOf(match[1]!)))].filter(
(name) => !name.startsWith('node:'),
);
diff --git a/plugins/code-editor/src/widgets/script/EditorGroupView.svelte b/plugins/code-editor/src/widgets/script/EditorGroupView.svelte
index 08d9b864..5175563f 100644
--- a/plugins/code-editor/src/widgets/script/EditorGroupView.svelte
+++ b/plugins/code-editor/src/widgets/script/EditorGroupView.svelte
@@ -16,6 +16,7 @@
import type { CodeEditorService } from '../../service/code-editor-service';
import type { EditorGroup } from '../../service/code-editor.type';
import { SETTING } from '../../service/code-editor-settings.const';
+ import { disposeDiffEditor } from '../../monaco/dispose-diff-editor';
import type { Monaco } from '../../monaco/monaco';
interface Props {
@@ -139,14 +140,8 @@
return !current || current === window.document.body || !!groupElement?.contains(current);
};
- /**
- * Detaches the diff editor's models before disposing it: a diff still being computed would
- * otherwise make widgets through its disposed services ("InstantiationService has been
- * disposed").
- */
const disposeDiff = () => {
- diff?.setModel(null);
- diff?.dispose();
+ if (diff) disposeDiffEditor(monaco, diff);
diff = undefined;
};
diff --git a/plugins/code-editor/test/document/editor-document.spec.ts b/plugins/code-editor/test/document/editor-document.spec.ts
new file mode 100644
index 00000000..bcb5e05c
--- /dev/null
+++ b/plugins/code-editor/test/document/editor-document.spec.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { EditorDocument } from '../../src/document/editor-document';
+import type { DocumentHost } from '../../src/document/editor-document.type';
+
+vi.mock('../../src/monaco/monaco', () => ({ uriOf: (path: string) => path }));
+
+/** Just what a document reads of a Monaco model: its text and version. */
+const fakeModel = (initial: string) => {
+ let text = initial;
+ let version = 1;
+ const listeners = new Set<(event: { changes: [] }) => void>();
+ return {
+ getValue: () => text,
+ getAlternativeVersionId: () => version,
+ onDidChangeContent: (listener: (event: { changes: [] }) => void) => {
+ listeners.add(listener);
+ return { dispose: () => listeners.delete(listener) };
+ },
+ dispose: () => undefined,
+ type(more: string) {
+ text += more;
+ version++;
+ for (const listener of listeners) listener({ changes: [] });
+ },
+ };
+};
+
+/** A file on a server that refuses a write based on another hash than the file's. */
+const setup = async () => {
+ const disk = { text: 'a', hash: 'h0', writes: 0 };
+ const model = fakeModel(disk.text);
+ let release: () => void = () => undefined;
+ let started: () => void = () => undefined;
+ const writing = new Promise((resolve) => (started = resolve));
+ const host = {
+ monaco: { editor: { getModel: () => model } },
+ unsaved: {
+ get: async () => undefined,
+ set: async () => undefined,
+ delete: async () => undefined,
+ },
+ beforeSave: async () => undefined,
+ backend: {
+ read: async () => ({ text: disk.text, hash: disk.hash }),
+ write: async (_path: string, text: string, base: string | null) => {
+ /** The first write waits, so a second save comes while it runs. */
+ if (disk.writes++ === 0)
+ await new Promise((resolve) => {
+ release = resolve;
+ started();
+ });
+ if (base !== disk.hash)
+ throw Object.assign(new Error('changed on disk'), { code: 'CONFLICT' });
+ disk.text = text;
+ disk.hash = `h${disk.writes}`;
+ return { hash: disk.hash };
+ },
+ },
+ } as unknown as DocumentHost;
+ const document = await EditorDocument.open(host, 'src/main.ts');
+ /** Lets the first write finish, once it is waiting. */
+ const finishFirst = async () => {
+ await writing;
+ release();
+ };
+ return { document, model, disk, finishFirst };
+};
+
+describe('EditorDocument', () => {
+ it('runs saves one at a time, each from the hash the last one wrote', async () => {
+ const { document, model, disk, finishFirst } = await setup();
+ model.type('b');
+ const first = document.save();
+ model.type('c');
+ const second = document.save();
+ await finishFirst();
+ await expect(Promise.all([first, second])).resolves.toBeDefined();
+ expect(disk.text).toBe('abc');
+ expect(document.conflict.get()).toBeUndefined();
+ expect(document.dirty.get()).toBe(false);
+ });
+
+ it('skips a save queued behind another when nothing changed since', async () => {
+ const { document, model, disk, finishFirst } = await setup();
+ model.type('b');
+ const first = document.save();
+ const second = document.save();
+ await finishFirst();
+ await Promise.all([first, second]);
+ expect(disk.writes).toBe(1);
+ });
+});
diff --git a/plugins/code-editor/test/document/unsaved-store.spec.ts b/plugins/code-editor/test/document/unsaved-store.spec.ts
new file mode 100644
index 00000000..8aaab4c1
--- /dev/null
+++ b/plugins/code-editor/test/document/unsaved-store.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { createUnsavedStore } from '../../src/document/unsaved-store';
+
+describe('createUnsavedStore', () => {
+ it('keeps unsaved texts per project in memory without IndexedDB', async () => {
+ const one = createUnsavedStore('one');
+ const other = createUnsavedStore('other');
+ const text = { text: 'draft', baseHash: 'h1', time: 1 };
+ await one.set('src/main.ts', text);
+ expect(await one.get('src/main.ts')).toEqual(text);
+ expect(await other.get('src/main.ts')).toBeUndefined();
+ await one.delete('src/main.ts');
+ expect(await one.get('src/main.ts')).toBeUndefined();
+ });
+});
diff --git a/plugins/code-editor/test/fixtures/fake-monaco.ts b/plugins/code-editor/test/fixtures/fake-monaco.ts
new file mode 100644
index 00000000..c66f1100
--- /dev/null
+++ b/plugins/code-editor/test/fixtures/fake-monaco.ts
@@ -0,0 +1,62 @@
+import { vi } from 'vitest';
+
+type Listener = (event: { changes: [] }) => void;
+
+/** Just what the code editor reads of a Monaco text model. */
+export const fakeModel = (initial: string) => {
+ let text = initial;
+ let version = 1;
+ const listeners = new Set();
+ const changed = () => {
+ version++;
+ for (const listener of listeners) listener({ changes: [] });
+ };
+ return {
+ getValue: () => text,
+ getAlternativeVersionId: () => version,
+ onDidChangeContent: (listener: Listener) => {
+ listeners.add(listener);
+ return { dispose: () => listeners.delete(listener) };
+ },
+ getFullModelRange: () => ({}),
+ pushStackElement: () => undefined,
+ pushEditOperations: (_selection: unknown, operations: { text: string }[]) => {
+ text = operations.map((operation) => operation.text).join('');
+ changed();
+ return null;
+ },
+ dispose: vi.fn(),
+ /** A user's keystrokes. */
+ type(more: string) {
+ text += more;
+ changed();
+ },
+ };
+};
+export type FakeModel = ReturnType;
+
+/** Monaco as the code editor uses it: models by path (`uriOf` is mocked to the path). */
+export const fakeMonaco = () => {
+ const models = new Map();
+ const extraLib = () => ({ dispose: () => undefined });
+ return {
+ models,
+ editor: {
+ getModel: (path: string) => models.get(path),
+ createModel: (text: string, _language: unknown, path: string) => {
+ const model = fakeModel(text);
+ models.set(path, model);
+ return model;
+ },
+ registerEditorOpener: () => ({ dispose: () => undefined }),
+ setModelMarkers: vi.fn(),
+ defineTheme: vi.fn(),
+ setTheme: vi.fn(),
+ remeasureFonts: vi.fn(),
+ },
+ typescript: {
+ typescriptDefaults: { addExtraLib: extraLib },
+ javascriptDefaults: { addExtraLib: extraLib },
+ },
+ };
+};
diff --git a/plugins/code-editor/test/service/code-editor-service.spec.ts b/plugins/code-editor/test/service/code-editor-service.spec.ts
new file mode 100644
index 00000000..803581e6
--- /dev/null
+++ b/plugins/code-editor/test/service/code-editor-service.spec.ts
@@ -0,0 +1,211 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { CodeEditorService } from '../../src/service/code-editor-service';
+import { SETTING } from '../../src/service/code-editor-settings.const';
+import { fakeMonaco } from '../fixtures/fake-monaco';
+
+vi.mock('../../src/monaco/monaco', () => ({
+ uriOf: (path: string) => path,
+ pathOf: (uri: string) => uri,
+}));
+vi.mock('@nanoforge-dev/editor-sdk/ui', () => ({
+ notifyError: vi.fn(),
+ parseColor: () => [0, 0, 0, 1],
+ toHex: () => '#000000',
+}));
+
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+const setup = (settings: Record = {}) => {
+ const disk = new Map([
+ ['src/a.ts', { text: 'a', hash: 'a0' }],
+ ['src/b.ts', { text: 'b', hash: 'b0' }],
+ ]);
+ const monaco = fakeMonaco();
+ let onChange: (changes: { path: string; type: string }[]) => void = () => undefined;
+ const register = vi.fn(() => ({ dispose: () => undefined }));
+ const backend = {
+ read: async (path: string) => disk.get(path)!,
+ write: async (path: string, text: string) => {
+ const hash = `${path}:${text}`;
+ disk.set(path, { text, hash });
+ return { hash };
+ },
+ };
+ const service = new CodeEditorService({
+ monaco: monaco as never,
+ project: {
+ id: `p${Math.random()}`,
+ fs: {
+ entries: [],
+ entry: (path: string) => (disk.has(path) ? { path, kind: 'file' } : undefined),
+ onDidChange: (listener: typeof onChange) => {
+ onChange = listener;
+ return { dispose: () => undefined };
+ },
+ readMany: async () => undefined,
+ readText: async (path: string) => ({ text: disk.get(path)?.text }),
+ },
+ model: { get: () => ({ apps: [] }) },
+ } as never,
+ documents: { backend, register, historyContext: vi.fn(), saveAll: async () => [] } as never,
+ code: undefined,
+ diagnostics: undefined,
+ settings: {
+ get: (key: string) => settings[key],
+ observe: () => ({ get: () => undefined, subscribe: () => () => undefined }),
+ } as never,
+ notifications: undefined,
+ logger: { warn: vi.fn(), error: vi.fn() } as never,
+ });
+ return {
+ service,
+ monaco,
+ disk,
+ register,
+ change: (path: string, type: string) => onChange([{ path, type }]),
+ };
+};
+
+afterEach(() => vi.useRealTimers());
+
+describe('CodeEditorService', () => {
+ it('opens files as tabs of the focused group, once each', async () => {
+ const { service, register } = setup();
+ await service.open('src/a.ts');
+ await service.open('src/b.ts');
+ await service.open('src/a.ts');
+ expect(service.layout.get()).toEqual({
+ groups: [{ tabs: ['src/a.ts', 'src/b.ts'], active: 'src/a.ts' }],
+ focused: 0,
+ });
+ expect(register).toHaveBeenCalledTimes(2);
+ expect(service.activePath()).toBe('src/a.ts');
+ });
+
+ it('splits to the right, and drops a group once its last tab closes', async () => {
+ const { service } = setup();
+ await service.open('src/a.ts');
+ service.splitRight();
+ expect(service.layout.get().groups).toEqual([
+ { tabs: ['src/a.ts'], active: 'src/a.ts' },
+ { tabs: ['src/a.ts'], active: 'src/a.ts' },
+ ]);
+ await service.open('src/b.ts', { group: 1, line: 3 });
+ expect(service.takeReveal(1, 'src/b.ts')).toEqual({
+ group: 1,
+ path: 'src/b.ts',
+ line: 3,
+ column: 1,
+ });
+ expect(service.takeReveal(1, 'src/b.ts')).toBeUndefined();
+ service.close(1, 'src/a.ts');
+ service.close(1, 'src/b.ts');
+ expect(service.layout.get()).toEqual({
+ groups: [{ tabs: ['src/a.ts'], active: 'src/a.ts' }],
+ focused: 0,
+ });
+ expect(service.document('src/b.ts')).toBeUndefined();
+ expect(service.document('src/a.ts')).toBeDefined();
+ });
+
+ it('asks before closing unsaved changes: cancel keeps, discard reverts, save writes', async () => {
+ const { service, monaco, disk } = setup();
+ await service.open('src/a.ts');
+ monaco.models.get('src/a.ts')!.type('!');
+ service.requestClose(0, 'src/a.ts');
+ expect(service.closing.get()).toEqual({ group: 0, path: 'src/a.ts' });
+ await service.finishClose('cancel');
+ expect(service.activePath()).toBe('src/a.ts');
+
+ service.requestClose(0, 'src/a.ts');
+ await service.finishClose('save');
+ expect(disk.get('src/a.ts')?.text).toBe('a!');
+ expect(service.activePath()).toBeUndefined();
+
+ await service.open('src/b.ts');
+ monaco.models.get('src/b.ts')!.type('?');
+ service.requestClose(0, 'src/b.ts');
+ await service.finishClose('discard');
+ expect(disk.get('src/b.ts')?.text).toBe('b');
+ expect(service.layout.get().groups[0]?.tabs).toEqual([]);
+ });
+
+ it('closes a clean file deleted on disk, and keeps an unsaved one in conflict', async () => {
+ const { service, monaco, change } = setup();
+ await service.open('src/a.ts');
+ await service.open('src/b.ts');
+ monaco.models.get('src/b.ts')!.type('?');
+ change('src/a.ts', 'deleted');
+ change('src/b.ts', 'deleted');
+ await flush();
+ expect(service.layout.get().groups[0]?.tabs).toEqual(['src/b.ts']);
+ expect(service.document('src/b.ts')?.conflict.get()).toEqual({
+ diskText: null,
+ diskHash: null,
+ });
+ });
+
+ it('shows a file next to another text until the comparison or the tab closes', async () => {
+ const { service } = setup();
+ await service.compare('src/a.ts', { text: 'old', label: 'Last commit' });
+ expect(service.comparisons.get().get('src/a.ts')?.label).toBe('Last commit');
+ service.closeComparison('src/a.ts');
+ expect(service.comparisons.get().size).toBe(0);
+ await service.compare('src/a.ts', { text: 'old', label: 'Last commit' });
+ service.close(0, 'src/a.ts');
+ expect(service.comparisons.get().size).toBe(0);
+ });
+
+ it('saves after a delay when autosave is on, and on focus loss when asked', async () => {
+ const { service, monaco, disk } = setup({
+ [SETTING.autoSaveAfterDelay]: true,
+ [SETTING.autoSaveDelayMs]: 1000,
+ [SETTING.autoSaveOnFocusLoss]: true,
+ });
+ await service.open('src/a.ts');
+ await service.open('src/b.ts');
+ vi.useFakeTimers();
+ monaco.models.get('src/a.ts')!.type('1');
+ await vi.advanceTimersByTimeAsync(999);
+ expect(disk.get('src/a.ts')?.text).toBe('a');
+ await vi.advanceTimersByTimeAsync(1);
+ expect(disk.get('src/a.ts')?.text).toBe('a1');
+ vi.useRealTimers();
+ monaco.models.get('src/b.ts')!.type('2');
+ service.focusLost();
+ await flush();
+ await flush();
+ expect(disk.get('src/b.ts')?.text).toBe('b2');
+ });
+
+ it('restores the tabs of files that still exist', async () => {
+ const { service } = setup();
+ await service.restore({
+ groups: [
+ { tabs: ['src/gone.ts', 'src/b.ts'], active: 'src/gone.ts' },
+ { tabs: ['src/a.ts'], active: 'src/a.ts' },
+ { tabs: ['src/c.ts'], active: undefined },
+ ],
+ focused: 4,
+ });
+ expect(service.layout.get()).toEqual({
+ groups: [
+ { tabs: ['src/b.ts'], active: 'src/b.ts' },
+ { tabs: ['src/a.ts'], active: 'src/a.ts' },
+ ],
+ focused: 1,
+ });
+ expect(service.document('src/a.ts')).toBeDefined();
+ });
+
+ it("follows the theme's code colors, and remeasures only when the code font changes", () => {
+ const { service, monaco } = setup();
+ const theme = { id: 't', label: 'T', kind: 'dark', colors: {} } as never;
+ service.applyTheme(theme);
+ service.applyTheme({ ...(theme as object), tokens: { 'font-code': 'Mono' } } as never);
+ service.applyTheme({ ...(theme as object), tokens: { 'font-code': 'Mono' } } as never);
+ expect(monaco.editor.setTheme).toHaveBeenCalledTimes(3);
+ expect(monaco.editor.remeasureFonts).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/plugins/code-editor/test/typing/type-libraries.spec.ts b/plugins/code-editor/test/typing/type-libraries.spec.ts
index 8325ea6e..8f2b7a51 100644
--- a/plugins/code-editor/test/typing/type-libraries.spec.ts
+++ b/plugins/code-editor/test/typing/type-libraries.spec.ts
@@ -1,17 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
-import type { ClientProject, CodeService } from '@nanoforge-dev/editor-sdk';
-
-import type { Monaco } from '../../src/monaco/monaco';
import { TypeLibraries } from '../../src/typing/type-libraries';
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+const setup = (files: Record) => {
+ const libs = new Map();
+ const addExtraLib = (text: string, uri: string) => {
+ libs.set(uri, text);
+ return { dispose: () => libs.delete(uri) };
+ };
+ const monaco = {
+ typescript: {
+ typescriptDefaults: { addExtraLib },
+ javascriptDefaults: { addExtraLib: () => ({ dispose: () => undefined }) },
+ },
+ };
+ let onChange: (changes: { path: string; type: string }[]) => void = () => undefined;
+ const project = {
+ fs: {
+ entries: Object.keys(files).map((path) => ({ path, kind: 'file' })),
+ onDidChange: (listener: typeof onChange) => {
+ onChange = listener;
+ return { dispose: () => undefined };
+ },
+ readMany: vi.fn(async () => undefined),
+ readText: async (path: string) => ({ text: files[path] }),
+ },
+ model: { get: () => ({ apps: [{ root: 'apps/client' }, { root: 'apps/server' }] }) },
+ };
+ const typeFiles = vi.fn(async (module: string) => [
+ { path: `node_modules/${module}/index.d.ts`, text: `declare module '${module}';` },
+ ]);
+ const libraries = new TypeLibraries(monaco as never, project as never, { typeFiles } as never);
+ return {
+ libraries,
+ libs,
+ typeFiles,
+ change: (path: string, type: string) => onChange([{ path, type }]),
+ };
+};
+
const deferred = () => {
let resolve!: (value: T) => void;
const promise = new Promise((done) => (resolve = done));
return { promise, resolve };
};
-const setup = (count: number) => {
+/** Reads held until the test answers them, to dispose while a load is on its way. */
+const setupHeld = (count: number) => {
const reads = new Map>>();
const paths = Array.from({ length: count }, (_, index) => `apps/client/src/file-${index}.ts`);
const project = {
@@ -26,7 +63,7 @@ const setup = (count: number) => {
},
},
model: { get: () => ({ apps: [{ root: 'apps/client' }] }) },
- } as unknown as ClientProject;
+ };
const added: string[] = [];
const addExtraLib = vi.fn((_text: string, uri: string) => {
added.push(uri);
@@ -37,35 +74,83 @@ const setup = (count: number) => {
typescriptDefaults: { addExtraLib },
javascriptDefaults: { addExtraLib: () => ({ dispose: () => undefined }) },
},
- } as unknown as Monaco;
+ };
const types = deferred<{ path: string; text: string }[]>();
- const code = { typeFiles: () => types.promise } as unknown as CodeService;
- return { reads, added, libraries: new TypeLibraries(monaco, project, code), types };
+ const code = { typeFiles: () => types.promise };
+ return {
+ reads,
+ added,
+ libraries: new TypeLibraries(monaco as never, project as never, code as never),
+ types,
+ };
};
-const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
-
describe('TypeLibraries', () => {
+ it("gives Monaco the project's code files and the declarations they import, once per app", async () => {
+ const { libs, typeFiles } = setup({
+ 'apps/client/src/main.ts': `import { a } from '@nanoforge-dev/core/client';\nimport fs from 'node:fs';`,
+ 'apps/client/src/b.ts': `const x = await import('@nanoforge-dev/core');`,
+ 'apps/server/src/main.ts': `import { b } from "@nanoforge-dev/core"; import './local';`,
+ 'apps/client/node_modules/x/index.ts': 'ignored',
+ 'apps/client/README.md': 'not code',
+ });
+ await flush();
+ await flush();
+ expect([...libs.keys()].sort()).toEqual([
+ 'file:///apps/client/src/b.ts',
+ 'file:///apps/client/src/main.ts',
+ 'file:///apps/server/src/main.ts',
+ 'file:///node_modules/@nanoforge-dev/core/index.d.ts',
+ ]);
+ expect(
+ typeFiles.mock.calls.map(([module, from]) => `${module} ${from.split('/')[1]}`).sort(),
+ ).toEqual(['@nanoforge-dev/core client', '@nanoforge-dev/core server']);
+ });
+
+ it('leaves a file open in an editor to its model, and follows changes on disk', async () => {
+ const { libraries, libs, change } = setup({ 'src/a.ts': 'export const a = 1;' });
+ await flush();
+ libraries.modelOpened('src/a.ts');
+ expect(libs.has('file:///src/a.ts')).toBe(false);
+ change('src/a.ts', 'changed');
+ await flush();
+ expect(libs.has('file:///src/a.ts')).toBe(false);
+ libraries.modelClosed('src/a.ts');
+ await flush();
+ expect(libs.has('file:///src/a.ts')).toBe(true);
+ change('src/a.ts', 'deleted');
+ expect(libs.has('file:///src/a.ts')).toBe(false);
+ libraries.dispose();
+ });
+
+ it('keeps declaration texts for read-only tabs', async () => {
+ const { libraries } = setup({ 'src/a.ts': `import 'pkg';` });
+ await flush();
+ await flush();
+ expect(libraries.library('node_modules/pkg/index.d.ts')).toBe("declare module 'pkg';");
+ expect(libraries.library('nope.d.ts')).toBeUndefined();
+ });
+
it("adds none of the old project's files once disposed", async () => {
- const { reads, added, libraries } = setup(20);
- await settle();
+ const { reads, added, libraries } = setupHeld(20);
+ await flush();
expect(reads.size).toBe(8);
libraries.dispose();
for (const read of reads.values()) read.resolve({ text: 'export const a = 1;\n' });
- await settle();
+ await flush();
expect(added).toEqual([]);
expect(reads.size).toBe(8);
});
it('adds no package declarations that arrive after dispose', async () => {
- const { reads, added, libraries, types } = setup(1);
- await settle();
+ const { reads, added, libraries, types } = setupHeld(1);
+ await flush();
reads.get('apps/client/src/file-0.ts')!.resolve({ text: "import { a } from 'speed';\n" });
- await settle();
+ await flush();
expect(added).toEqual(['file:///apps/client/src/file-0.ts']);
libraries.dispose();
types.resolve([{ path: 'node_modules/speed/index.d.ts', text: 'export declare const a: 1;' }]);
- await settle();
+ await flush();
expect(added).toEqual(['file:///apps/client/src/file-0.ts']);
});
});
diff --git a/plugins/command-palette/package.json b/plugins/command-palette/package.json
index 33cb77e2..9306508d 100644
--- a/plugins/command-palette/package.json
+++ b/plugins/command-palette/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/command-palette/src/palette/palette-model.ts b/plugins/command-palette/src/palette/palette-model.ts
index b2d9ae99..7c5e586d 100644
--- a/plugins/command-palette/src/palette/palette-model.ts
+++ b/plugins/command-palette/src/palette/palette-model.ts
@@ -45,7 +45,7 @@ const MAX_RESULTS = 60;
* Items whose label matches the query, best first. `boost` breaks ties and orders an empty
* query (recent actions, open files): lower comes first, undefined last.
*/
-export const rank = (
+const rank = (
items: readonly T[],
query: string,
label: (item: T) => string,
diff --git a/plugins/command-palette/test/keymap/keymap-rows.spec.ts b/plugins/command-palette/test/keymap/keymap-rows.spec.ts
new file mode 100644
index 00000000..d38c4ee0
--- /dev/null
+++ b/plugins/command-palette/test/keymap/keymap-rows.spec.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import type { EditorAction, ResolvedKeybinding } from '@nanoforge-dev/editor-sdk/ui';
+
+import {
+ conflictsOf,
+ filterRows,
+ keymapRows,
+ strokesOf,
+ userConflicts,
+} from '../../src/keymap/keymap-rows';
+
+vi.mock('@nanoforge-dev/editor-sdk/ui', () => ({
+ actionId: (command: string, args: readonly unknown[]) =>
+ args.length ? `${command}:${JSON.stringify(args)}` : command,
+ actionLabel: (action: { title: string; category?: string }) =>
+ action.category ? `${action.category}: ${action.title}` : action.title,
+ bindingsConflict: (a: ResolvedKeybinding, b: ResolvedKeybinding) =>
+ a.key === b.key && a.command !== b.command,
+ findConflicts: (bindings: readonly ResolvedKeybinding[]) =>
+ [...Map.groupBy(bindings, (binding) => binding.key).values()]
+ .filter((group) => group.length > 1)
+ .map((group) => ({ bindings: group })),
+ formatKeybinding: (key: string) => key.replace('Mod', 'Ctrl'),
+ isActionChanged: (overrides: readonly { command: string }[], command: string) =>
+ overrides.some((override) => override.command === command),
+ parseKeybinding: (key: string) => {
+ if (!key.trim()) throw new Error('empty');
+ return key.split(' ');
+ },
+}));
+
+const action = (command: string, title: string, category?: string): EditorAction => ({
+ id: command,
+ command,
+ args: [],
+ title,
+ ...(category && { category }),
+ when: [],
+});
+
+const binding = (
+ command: string,
+ key: string,
+ source: ResolvedKeybinding['source'] = 'default',
+): ResolvedKeybinding => ({ command, args: [], key, strokes: key.split(' '), source });
+
+const SAVE = action('file.save', 'Save', 'File');
+const PLAY = action('run.play', 'Play', 'Run');
+const BINDINGS = [binding('file.save', 'Mod+S'), binding('editor.hidden', 'Mod+H')];
+const LAYERS = { before: [], own: [{ command: 'run.play' }], after: [] } as never;
+
+describe('keymap rows', () => {
+ it('lists every action, and the untitled commands that have shortcuts', () => {
+ const rows = keymapRows([SAVE, PLAY], LAYERS, BINDINGS, new Set());
+ expect(rows.map((row) => [row.label, row.bindings.length, row.changed])).toEqual([
+ ['File: Save', 1, false],
+ ['Run: Play', 0, true],
+ ['editor.hidden', 1, false],
+ ]);
+ });
+
+ it('marks the actions whose shortcuts conflict with a change of the user', () => {
+ const mine = binding('run.play', 'Mod+S', 'user');
+ const bindings = [...BINDINGS, mine];
+ const conflicting = userConflicts(bindings);
+ expect(conflicting).toEqual(new Set([BINDINGS[0], mine]));
+ expect(userConflicts(BINDINGS)).toEqual(new Set());
+ const rows = keymapRows([SAVE, PLAY], LAYERS, bindings, conflicting);
+ expect(rows.filter((row) => row.conflict).map((row) => row.action.id)).toEqual([
+ 'file.save',
+ 'run.play',
+ ]);
+ });
+
+ it('filters by label, command, shortcut, changes and conflicts', () => {
+ const rows = keymapRows([SAVE, PLAY], LAYERS, BINDINGS, new Set([BINDINGS[0]!]));
+ const ids = (query: string, onlyChanged = false, onlyConflicts = false) =>
+ filterRows(rows, { query, onlyChanged, onlyConflicts }).map((row) => row.action.id);
+ expect(ids(' SAVE ')).toEqual(['file.save']);
+ expect(ids('run.')).toEqual(['run.play']);
+ expect(ids('ctrl+h')).toEqual(['editor.hidden']);
+ expect(ids('mod+s')).toEqual(['file.save']);
+ expect(ids('', true)).toEqual(['run.play']);
+ expect(ids('', false, true)).toEqual(['file.save']);
+ expect(ids('nothing')).toEqual([]);
+ });
+
+ it('parses strokes, and finds what a candidate shortcut would get in the way of', () => {
+ expect(strokesOf('Mod+K S')).toEqual(['Mod+K', 'S']);
+ expect(strokesOf(' ')).toBeUndefined();
+ expect(conflictsOf(BINDINGS, PLAY, 'Mod+S', undefined)).toEqual([BINDINGS[0]]);
+ expect(conflictsOf(BINDINGS, PLAY, 'Mod+S', 'editorFocus', BINDINGS[0])).toEqual([]);
+ expect(conflictsOf(BINDINGS, PLAY, ' ', undefined)).toEqual([]);
+ });
+});
diff --git a/plugins/console/package.json b/plugins/console/package.json
index 27dd8ffb..5a66782f 100644
--- a/plugins/console/package.json
+++ b/plugins/console/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/console/src/problems/group-problems.ts b/plugins/console/src/problems/group-problems.ts
index c663dc34..e49e6d75 100644
--- a/plugins/console/src/problems/group-problems.ts
+++ b/plugins/console/src/problems/group-problems.ts
@@ -19,7 +19,7 @@ export interface ProblemFilter {
}
/** `build:apps/client` → `Build of apps/client`; other sources keep their name. */
-export const sourceTitle = (source: string): string => {
+const sourceTitle = (source: string): string => {
const build = /^build:(.*)$/.exec(source);
return build ? `Build of ${build[1] || 'the project'}` : source;
};
diff --git a/plugins/console/src/store/console-store.ts b/plugins/console/src/store/console-store.ts
index fa16cc70..26f01731 100644
--- a/plugins/console/src/store/console-store.ts
+++ b/plugins/console/src/store/console-store.ts
@@ -41,7 +41,7 @@ const hasTree = (values: readonly LogValue[]): boolean =>
values.some((value) => value !== null && typeof value === 'object');
/** A log entry as a console line: values are snapshots taken now. */
-export const toLine = (entry: LogEntry, id: number): ConsoleLine => {
+const toLine = (entry: LogEntry, id: number): ConsoleLine => {
const values =
entry.values ??
(entry.args.length
diff --git a/plugins/ecs/package.json b/plugins/ecs/package.json
index d4459c5a..53464b8e 100644
--- a/plugins/ecs/package.json
+++ b/plugins/ecs/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/ecs/src/create/create-item.ts b/plugins/ecs/src/create/create-item.ts
index 73fb15a8..e37eeaeb 100644
--- a/plugins/ecs/src/create/create-item.ts
+++ b/plugins/ecs/src/create/create-item.ts
@@ -22,10 +22,10 @@ export const validateItemName = (kind: ItemKind, name: string): string | undefin
};
/** `@side` of a new item: the app's side, `shared` in a shared library. */
-export const sideOf = (target: AppModel): 'client' | 'server' | 'shared' =>
+const sideOf = (target: AppModel): 'client' | 'server' | 'shared' =>
target.type === 'lib' ? 'shared' : target.type;
-export const itemTemplate = (kind: ItemKind, name: string, target: AppModel): string => {
+const itemTemplate = (kind: ItemKind, name: string, target: AppModel): string => {
const side = sideOf(target);
if (kind === 'component') {
return [
@@ -66,7 +66,7 @@ export const itemTemplate = (kind: ItemKind, name: string, target: AppModel): st
};
/** Path of a new item's file in an app or shared library. */
-export const itemPath = (kind: ItemKind, name: string, target: AppModel): string =>
+const itemPath = (kind: ItemKind, name: string, target: AppModel): string =>
`${kind === 'component' ? target.dirs.components : target.dirs.systems}/${kebab(name)}.ts`;
export const createItem = async (
diff --git a/plugins/ecs/src/extension/field-editor.extension-point.ts b/plugins/ecs/src/extension/field-editor.extension-point.ts
index 6e6f8753..8c10edb6 100644
--- a/plugins/ecs/src/extension/field-editor.extension-point.ts
+++ b/plugins/ecs/src/extension/field-editor.extension-point.ts
@@ -9,7 +9,7 @@ import type { ArgModel, ArgValue, ImportNeed } from '../model/ecs-model.type';
* `@nanoforge/ecs` as a dependency): `defineExtensionPoint('ecs.fieldEditors')`.
*/
-export interface FieldEditorProps {
+interface FieldEditorProps {
readonly element: Element;
readonly arg: ArgModel | undefined;
readonly label: string;
diff --git a/plugins/ecs/src/extension/param-preset.extension-point.ts b/plugins/ecs/src/extension/param-preset.extension-point.ts
index afc34ca6..2652a2ae 100644
--- a/plugins/ecs/src/extension/param-preset.extension-point.ts
+++ b/plugins/ecs/src/extension/param-preset.extension-point.ts
@@ -5,7 +5,7 @@ import { defineExtensionPoint } from '@nanoforge-dev/editor-sdk';
import type { PresetDefinition, PresetRow } from '../layout/param-layout';
import type { ArgValue } from '../model/ecs-model.type';
-export interface PresetEditorProps {
+interface PresetEditorProps {
readonly row: PresetRow;
/** The value of each slot (same order as `row.slots`). */
readonly values: readonly unknown[];
diff --git a/plugins/ecs/src/layout/param-layout.ts b/plugins/ecs/src/layout/param-layout.ts
index 7699efdd..b037ae93 100644
--- a/plugins/ecs/src/layout/param-layout.ts
+++ b/plugins/ecs/src/layout/param-layout.ts
@@ -43,7 +43,7 @@ export const BUILT_IN_PRESETS: readonly PresetDefinition[] = [
},
];
-export interface ParamRow {
+interface ParamRow {
readonly kind: 'param';
readonly index: number;
readonly element: Element;
@@ -68,16 +68,16 @@ export interface PresetRow {
export type LayoutRow = ParamRow | PresetRow;
-export interface GroupBlock {
+interface GroupBlock {
readonly kind: 'group';
readonly group: ParamGroup;
readonly rows: readonly LayoutRow[];
readonly hidden: boolean;
}
-export type LayoutBlock = LayoutRow | GroupBlock;
+type LayoutBlock = LayoutRow | GroupBlock;
-export interface HiddenEntry {
+interface HiddenEntry {
/** `param:` or `group:`. */
readonly id: string;
readonly label: string;
@@ -90,7 +90,7 @@ export interface ParamLayout {
readonly warnings: readonly string[];
}
-export const paramLabel = (element: Element): string => element.layout?.label ?? element.name;
+const paramLabel = (element: Element): string => element.layout?.label ?? element.name;
const presetFor = (
presets: readonly PresetDefinition[],
diff --git a/plugins/ecs/src/live/live-service.ts b/plugins/ecs/src/live/live-service.ts
index b4897768..2380bf39 100644
--- a/plugins/ecs/src/live/live-service.ts
+++ b/plugins/ecs/src/live/live-service.ts
@@ -21,8 +21,8 @@ import { ecsData } from '../service/ecs-data';
import type { EcsService } from '../service/ecs-service';
import type { EngineWorld, LiveEntity, LiveInstance, LiveSelection, LiveSource } from './live.type';
-export const AUTO_APPLY = '@nanoforge/ecs.autoApply';
-export const RATE = '@nanoforge/ecs.liveRateMs';
+const AUTO_APPLY = '@nanoforge/ecs.autoApply';
+const RATE = '@nanoforge/ecs.liveRateMs';
/** Changes made while playing, by app, entity key (`liveKey`), component name and field. */
export type Pending = Map>>>;
@@ -387,20 +387,20 @@ export class LiveService implements Disposable {
}
}
-export const siteKey = (
+const siteKey = (
app: string,
site: { file: string; line: number; column: number },
scene?: string,
) => `${app}|${scene ?? ''}|${site.file.replace(/\?.*$/, '')}|${site.line}|${site.column}`;
/** The scene and variable of a `LiveEntity.key`. */
-export const parseKey = (key: string): { scene?: string; name: string } => {
+const parseKey = (key: string): { scene?: string; name: string } => {
const colon = key.indexOf(':');
return colon < 0 ? { name: key } : { scene: key.slice(0, colon), name: key.slice(colon + 1) };
};
/** The `spawnEntity()` calls of `main` in text order: entity variables and "from code" ones. */
-export const spawnSites = (
+const spawnSites = (
model: EntryModel | undefined,
): { name?: string; line: number; start: number }[] =>
[
@@ -432,7 +432,7 @@ export const withoutApplied = (
return next;
};
-export const clonePending = (pending: Pending): Pending =>
+const clonePending = (pending: Pending): Pending =>
new Map(
[...pending].map(([app, entities]) => [
app,
diff --git a/plugins/ecs/src/model/arg-values.ts b/plugins/ecs/src/model/arg-values.ts
index 3a8bfcaa..c36e370a 100644
--- a/plugins/ecs/src/model/arg-values.ts
+++ b/plugins/ecs/src/model/arg-values.ts
@@ -37,7 +37,7 @@ export const initialArgs = (params: readonly Element[]): ArgValue[] => {
};
/** `@nanoforge-dev/input#InputEnum` → the enum's name and module. */
-export const splitRef = (ref: string): { module: string; name: string } => {
+const splitRef = (ref: string): { module: string; name: string } => {
const index = ref.lastIndexOf('#');
return { module: ref.slice(0, index), name: ref.slice(index + 1) };
};
diff --git a/plugins/ecs/src/scene/build-scene.ts b/plugins/ecs/src/scene/build-scene.ts
index cf8685d4..bbf6ccd3 100644
--- a/plugins/ecs/src/scene/build-scene.ts
+++ b/plugins/ecs/src/scene/build-scene.ts
@@ -10,7 +10,7 @@ import type { CatalogItem } from '@nanoforge-dev/editor-sdk';
import type { LiveEntity } from '../live/live.type';
import type { ArgModel, EntryModel } from '../model/ecs-model.type';
-export interface ScenePosition {
+interface ScenePosition {
/** Index of the component in the entity (for `setArgs`). */
readonly component: number;
/** ECS name of the component (for live edits). */
diff --git a/plugins/ecs/src/service/ecs-service.ts b/plugins/ecs/src/service/ecs-service.ts
index 0d671f7a..481da473 100644
--- a/plugins/ecs/src/service/ecs-service.ts
+++ b/plugins/ecs/src/service/ecs-service.ts
@@ -6,7 +6,6 @@ import {
CodeServiceToken,
type Disposable,
DisposableStore,
- EditorServices,
type Logger,
type Observable,
ObservableValue,
@@ -542,5 +541,3 @@ export class EcsService implements Disposable {
}
}
export const EcsServiceToken = createToken('ecs.service');
-
-export { EditorServices };
diff --git a/plugins/ecs/src/worker/bundle-spawns.ts b/plugins/ecs/src/worker/bundle-spawns.ts
index dac63ba9..e3bb839b 100644
--- a/plugins/ecs/src/worker/bundle-spawns.ts
+++ b/plugins/ecs/src/worker/bundle-spawns.ts
@@ -2,7 +2,7 @@ import { ts } from 'ts-morph';
import type { AnalyzeContext } from '@nanoforge-dev/editor-sdk/worker';
-export interface BundleSite {
+interface BundleSite {
/** 1-based, as in stack traces. */
readonly line: number;
readonly column: number;
@@ -12,7 +12,7 @@ export interface BundleSite {
readonly enclosing?: boolean;
}
-export interface BundleSpawnsArgs {
+interface BundleSpawnsArgs {
/** The built entry file (`main.js`). */
readonly bundle: string;
readonly sites: readonly BundleSite[];
diff --git a/plugins/ecs/src/worker/component-args.ts b/plugins/ecs/src/worker/component-args.ts
index ed3b598e..23ad4de0 100644
--- a/plugins/ecs/src/worker/component-args.ts
+++ b/plugins/ecs/src/worker/component-args.ts
@@ -1,4 +1,4 @@
-import { type CallExpression, type ExpressionStatement, type NewExpression, Node } from 'ts-morph';
+import { type ExpressionStatement, type NewExpression, Node } from 'ts-morph';
import type { TransformContext } from '@nanoforge-dev/editor-sdk/worker';
@@ -6,9 +6,6 @@ import type { ArgValue } from '../model/ecs-model.type';
import { componentExpression } from './entry-file';
import { fail, literal } from './transform-helpers';
-export const componentCall = (statement: ExpressionStatement): CallExpression =>
- statement.getExpression() as CallExpression;
-
/** Rewrites `new X(...)` arguments: in place when they exist, else the whole argument list. */
export const setArgs = (
context: TransformContext,
diff --git a/plugins/ecs/src/worker/entry-file.ts b/plugins/ecs/src/worker/entry-file.ts
index bc590367..25ce2e62 100644
--- a/plugins/ecs/src/worker/entry-file.ts
+++ b/plugins/ecs/src/worker/entry-file.ts
@@ -25,7 +25,7 @@ import type {
import { scopeName } from '../model/scope-name';
/** The body of `export const main = async (…) => { … }` or `export function main(…) { … }`. */
-export const findMain = (file: SourceFile): Block | undefined => {
+const findMain = (file: SourceFile): Block | undefined => {
const fn = file.getFunction('main');
if (fn?.isExported()) return fn.getBody() as Block | undefined;
const variable = file.getVariableDeclaration('main');
@@ -42,7 +42,7 @@ export const findMain = (file: SourceFile): Block | undefined => {
};
/** The body of a scope: `main`, or a method of a class of the file (`Level1.setup`). */
-export const findBody = (file: SourceFile, scope: EntryScope | undefined): Block | undefined => {
+const findBody = (file: SourceFile, scope: EntryScope | undefined): Block | undefined => {
if (scope?.kind !== 'method') return findMain(file);
const body = file.getClass(scope.class)?.getMethod(scope.method)?.getBody();
return body && Node.isBlock(body) ? body : undefined;
diff --git a/plugins/ecs/src/worker/split-items.ts b/plugins/ecs/src/worker/split-items.ts
index b672f08a..4d464659 100644
--- a/plugins/ecs/src/worker/split-items.ts
+++ b/plugins/ecs/src/worker/split-items.ts
@@ -11,7 +11,7 @@ import { declaredNames, isExported, movedText, topStatement } from './top-statem
* Items to move out of the analyzed file: export name → the new file's project path. `entry`: the
* file is the app's entry, which imports the moved items: they must not import it back.
*/
-export interface SplitItemsArgs {
+interface SplitItemsArgs {
readonly items: readonly { readonly name: string; readonly to: string }[];
readonly entry?: boolean;
}
diff --git a/plugins/ecs/src/worker/transform-helpers.ts b/plugins/ecs/src/worker/transform-helpers.ts
index f67f6e66..f492d5bc 100644
--- a/plugins/ecs/src/worker/transform-helpers.ts
+++ b/plugins/ecs/src/worker/transform-helpers.ts
@@ -20,7 +20,7 @@ export const lastStatementOf = (entity: ParsedEntity): Statement =>
entity.components.at(-1) ?? entity.declaration;
/** Names already used in the file (declarations and references). */
-export const usedNames = (context: TransformContext): Set =>
+const usedNames = (context: TransformContext): Set =>
new Set(
context.file
.getDescendants()
diff --git a/plugins/ecs/src/worker/transforms.ts b/plugins/ecs/src/worker/transforms.ts
index 104f423a..083467a3 100644
--- a/plugins/ecs/src/worker/transforms.ts
+++ b/plugins/ecs/src/worker/transforms.ts
@@ -4,7 +4,7 @@ import type { TransformContext } from '@nanoforge-dev/editor-sdk/worker';
import type { ScopedEntryOp } from '../model/ecs-model.type';
import { scopeName } from '../model/scope-name';
-import { componentCall, setArgs } from './component-args';
+import { setArgs } from './component-args';
import { ensureImports } from './ensure-imports';
import { componentExpression, parseEntry } from './entry-file';
import {
@@ -199,5 +199,3 @@ export const transformEntry = (context: TransformContext, rawOp: unknown): void
}
}
};
-
-export { componentCall };
diff --git a/plugins/ecs/test/create/create-item.spec.ts b/plugins/ecs/test/create/create-item.spec.ts
new file mode 100644
index 00000000..d7cc5d2d
--- /dev/null
+++ b/plugins/ecs/test/create/create-item.spec.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { createItem, kebab, validateItemName } from '../../src/create/create-item';
+
+const app = (type: string) =>
+ ({
+ type,
+ dirs: { components: `apps/${type}/src/components`, systems: `apps/${type}/src/systems` },
+ }) as never;
+
+const project = (existing: string[] = []) => {
+ const files = new Map();
+ return {
+ files,
+ fs: {
+ entry: (path: string) => (existing.includes(path) ? { path } : undefined),
+ write: vi.fn(async (path: string, text: string) => void files.set(path, text)),
+ },
+ };
+};
+
+describe('createItem', () => {
+ it('names files in kebab case', () => {
+ expect(kebab('PlayerHealth')).toBe('player-health');
+ expect(kebab('moveBall2D')).toBe('move-ball2-d');
+ expect(kebab('__x y__')).toBe('x-y');
+ });
+
+ it('refuses names that are not identifiers of the right case', () => {
+ expect(validateItemName('component', '2D')).toMatch(/not starting with a digit/);
+ expect(validateItemName('component', 'player')).toMatch(/capital letter/);
+ expect(validateItemName('system', 'Move')).toMatch(/lowercase/);
+ expect(validateItemName('system', 'move')).toBeUndefined();
+ });
+
+ it("writes a tagged component for the app's side, shared in a library", async () => {
+ const target = project();
+ const path = await createItem(target as never, 'component', 'Health', app('lib'));
+ expect(path).toBe('apps/lib/src/components/health.ts');
+ expect(target.files.get(path)).toContain('@side shared');
+ expect(target.files.get(path)).toContain('export class Health {');
+ });
+
+ it("writes a system on the side's ECS, and never over a file", async () => {
+ const target = project(['apps/server/src/systems/tick.ts']);
+ const path = await createItem(target as never, 'system', 'move', app('server'));
+ expect(target.files.get(path)).toContain('from "@nanoforge-dev/ecs/server"');
+ expect(target.files.get(path)).toContain('@side server');
+ await expect(createItem(target as never, 'system', 'tick', app('server'))).rejects.toThrow(
+ 'already exists',
+ );
+ });
+});
diff --git a/plugins/ecs/test/model/arg-values.spec.ts b/plugins/ecs/test/model/arg-values.spec.ts
new file mode 100644
index 00000000..f9666614
--- /dev/null
+++ b/plugins/ecs/test/model/arg-values.spec.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+
+import type { Element } from '@nanoforge-dev/editor-sdk';
+
+import {
+ currentValue,
+ defaultArg,
+ enumMemberArg,
+ hexColor,
+ hexToRgb,
+ initialArgs,
+ rgbToHex,
+} from '../../src/model/arg-values';
+
+const param = (extra: object) => ({ name: 'p', ...extra }) as unknown as Element;
+
+describe('arg values', () => {
+ it("shows the argument's value, else the param's default", () => {
+ expect(currentValue(param({ type: 'number', default: 3 }), undefined)).toBe(3);
+ expect(currentValue(param({ type: 'number', default: 3 }), { value: 5 } as never)).toBe(5);
+ expect(currentValue(param({ type: 'number' }), undefined)).toBeUndefined();
+ });
+
+ it('writes a default, the default code, undefined or a value of the type', () => {
+ expect(defaultArg(param({ type: 'number', default: 2 }))).toEqual({ value: 2 });
+ expect(defaultArg(param({ type: 'number', defaultCode: 'SPEED' }))).toEqual({ code: 'SPEED' });
+ expect(defaultArg(param({ type: 'number', optional: true }))).toEqual({ code: 'undefined' });
+ expect(defaultArg(param({ type: 'number' }))).toEqual({ value: 0 });
+ expect(defaultArg(param({ type: 'boolean' }))).toEqual({ value: false });
+ expect(defaultArg(param({ type: 'string', enum: ['left', 'right'] }))).toEqual({
+ value: 'left',
+ });
+ expect(defaultArg(param({ type: 'asset' }))).toEqual({ value: '' });
+ expect(defaultArg(param({ type: 'array' }))).toEqual({ value: [] });
+ expect(defaultArg(param({ type: 'object' }))).toEqual({ code: 'undefined' });
+ });
+
+ it('fills the params up to the last required one', () => {
+ expect(
+ initialArgs([
+ param({ type: 'number' }),
+ param({ type: 'boolean', optional: true }),
+ param({ type: 'string' }),
+ param({ type: 'number', optional: true }),
+ ]),
+ ).toEqual([{ value: 0 }, { code: 'undefined' }, { value: '' }]);
+ expect(initialArgs([param({ type: 'number', optional: true })])).toEqual([]);
+ });
+
+ it("writes an enum member with its enum's import", () => {
+ expect(enumMemberArg('@nanoforge-dev/input#InputEnum', 'ArrowUp')).toEqual({
+ value: { code: 'InputEnum.ArrowUp' },
+ imports: [{ name: 'InputEnum', from: '@nanoforge-dev/input' }],
+ });
+ });
+
+ it('converts colors', () => {
+ expect(hexColor('#A0b1C2')).toBe('#A0b1C2');
+ expect(hexColor('red')).toBeUndefined();
+ expect(hexColor(3)).toBeUndefined();
+ expect(rgbToHex(255, 0, 300)).toBe('#ff00ff');
+ expect(rgbToHex(-5, 15.6, 128)).toBe('#001080');
+ expect(hexToRgb('#ff8000')).toEqual([255, 128, 0]);
+ });
+});
diff --git a/plugins/file-manager/package.json b/plugins/file-manager/package.json
index 6a2ab642..0f32c9e7 100644
--- a/plugins/file-manager/package.json
+++ b/plugins/file-manager/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/file-manager/src/service/file-manager-service.ts b/plugins/file-manager/src/service/file-manager-service.ts
index cd44e332..3c617712 100644
--- a/plugins/file-manager/src/service/file-manager-service.ts
+++ b/plugins/file-manager/src/service/file-manager-service.ts
@@ -21,8 +21,8 @@ import type { Clipboard, FileManagerServiceOptions } from './file-manager-servic
import { FileOperations } from './file-operations';
import { iconOfEntry } from './icon-of-entry';
-export const NAME = /^[^/\\:*?"<>|]+$/;
-export const validateName = (value: string) =>
+const NAME = /^[^/\\:*?"<>|]+$/;
+const validateName = (value: string) =>
!value.trim()
? 'Enter a name'
: !NAME.test(value) || value === '.' || value === '..'
diff --git a/plugins/file-manager/src/service/file-operations.ts b/plugins/file-manager/src/service/file-operations.ts
index 288fb634..62aac970 100644
--- a/plugins/file-manager/src/service/file-operations.ts
+++ b/plugins/file-manager/src/service/file-operations.ts
@@ -15,7 +15,7 @@ const splitName = (name: string): [string, string] => {
};
/** A free name in a folder: `name`, else `name copy`, `name copy 2`… */
-export const freeName = (
+const freeName = (
exists: (path: string) => boolean,
folder: string,
name: string,
@@ -30,11 +30,11 @@ export const freeName = (
};
/** Whether `path` is `folder` or inside it. */
-export const isInside = (path: string, folder: string): boolean =>
+const isInside = (path: string, folder: string): boolean =>
folder === '' || path === folder || path.startsWith(`${folder}/`);
/** The entries of a selection that are not inside another selected entry. */
-export const selectionRoots = (paths: readonly string[]): string[] =>
+const selectionRoots = (paths: readonly string[]): string[] =>
paths.filter((path) => !paths.some((other) => other !== path && isInside(path, other)));
interface Step {
@@ -46,7 +46,7 @@ interface Step {
* Runs steps in order, all or nothing: when one fails, those already done are undone (newest
* first) and the error is rethrown, so a failed operation leaves no half-done change behind.
*/
-export const runSteps = async (steps: readonly Step[]): Promise => {
+const runSteps = async (steps: readonly Step[]): Promise => {
const done: Step[] = [];
try {
for (const step of steps) {
diff --git a/plugins/file-manager/test/filter/file-filter.spec.ts b/plugins/file-manager/test/filter/file-filter.spec.ts
new file mode 100644
index 00000000..b00e2a02
--- /dev/null
+++ b/plugins/file-manager/test/filter/file-filter.spec.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+
+import { FileFilter, fuzzyScore, isPackagePath } from '../../src/filter/file-filter';
+
+describe('FileFilter', () => {
+ it('hides what gitignore patterns match, folders by their trailing slash', () => {
+ const filter = new FileFilter(['*.log', 'build/', '.*']);
+ expect(filter.excludes({ path: 'logs/run.log', kind: 'file' })).toBe(true);
+ expect(filter.excludes({ path: 'build', kind: 'directory' })).toBe(true);
+ expect(filter.excludes({ path: 'build', kind: 'file' })).toBe(false);
+ expect(filter.excludes({ path: 'src/.env', kind: 'file' })).toBe(true);
+ expect(filter.excludes({ path: 'src/main.ts', kind: 'file' })).toBe(false);
+ });
+});
+
+describe('isPackagePath', () => {
+ it('is nf_modules and what it holds, not a name that starts the same', () => {
+ expect(isPackagePath('nf_modules')).toBe(true);
+ expect(isPackagePath('nf_modules/@acme/ball/manifest.json')).toBe(true);
+ expect(isPackagePath('nf_modules2/x')).toBe(false);
+ expect(isPackagePath('src/nf_modules')).toBe(false);
+ });
+});
+
+describe('fuzzyScore', () => {
+ it('matches the characters of the query in order, on the file name only', () => {
+ expect(fuzzyScore('mts', 'src/main.ts')).toBeGreaterThan(0);
+ expect(fuzzyScore('MAIN', 'src/main.ts')).toBeGreaterThan(0);
+ expect(fuzzyScore('src', 'src/main.ts')).toBe(-1);
+ expect(fuzzyScore('nm', 'src/main.ts')).toBe(-1);
+ expect(fuzzyScore('', 'src/main.ts')).toBe(0);
+ });
+
+ it('ranks consecutive and early matches first, then shorter names', () => {
+ expect(fuzzyScore('main', 'main.ts')).toBeGreaterThan(fuzzyScore('main', 'my-animation.ts'));
+ expect(fuzzyScore('ma', 'main.ts')).toBeGreaterThan(fuzzyScore('ma', 'xmain.ts'));
+ expect(fuzzyScore('main', 'main.ts')).toBeGreaterThan(fuzzyScore('main', 'main-menu.ts'));
+ });
+});
diff --git a/plugins/file-manager/test/fixtures/service.ts b/plugins/file-manager/test/fixtures/service.ts
new file mode 100644
index 00000000..0069bc74
--- /dev/null
+++ b/plugins/file-manager/test/fixtures/service.ts
@@ -0,0 +1,78 @@
+import { vi } from 'vitest';
+
+import type { ContextKeyService, ExtensionPoint, FileEntry } from '@nanoforge-dev/editor-sdk';
+
+import { FileManagerService } from '../../src/service/file-manager-service';
+
+/** Context keys reading `key == value` clauses: what the menu's `when` tests use. */
+const contextKeys = () =>
+ ({
+ createScoped: () => {
+ const values = new Map();
+ return {
+ set: (key: string, value: unknown) => values.set(key, value),
+ dispose: () => values.clear(),
+ evaluate: (when: string | undefined) => {
+ if (!when) return true;
+ const [key, value] = when.split('==').map((part) => part.trim().replace(/^'|'$/g, ''));
+ return String(values.get(key!)) === value;
+ },
+ };
+ },
+ }) as unknown as ContextKeyService;
+
+const dirname = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
+
+/**
+ * A file manager on a project of a few entries; its operations, prompts and commands are mocks.
+ * Tests that import it mock `@nanoforge-dev/editor-sdk/ui` (its Svelte components).
+ */
+export const makeService = (
+ entries: FileEntry[] = [
+ { path: 'src', kind: 'directory' },
+ { path: 'src/main.ts', kind: 'file' },
+ { path: 'nf_modules', kind: 'directory' },
+ { path: 'nf_modules/ball.ts', kind: 'file' },
+ ] as FileEntry[],
+ values: Record = {},
+ { local = true } = {},
+) => {
+ const executeCommand = vi.fn(async () => undefined);
+ const ask = vi.fn<(options: { validate?: (value: string) => string | undefined }) => Promise>(
+ async () => undefined,
+ );
+ const notify = vi.fn();
+ const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() };
+ const service = new FileManagerService({
+ project: {
+ id: 'p1',
+ fs: {
+ entry: (path: string) => entries.find((entry) => entry.path === path),
+ children: (path: string) => entries.filter((entry) => dirname(entry.path) === path),
+ reveal: vi.fn(async () => undefined),
+ },
+ } as never,
+ history: { dispose: vi.fn() } as never,
+ extensions: {
+ getValues: (point: ExtensionPoint) => (values[point.id] ?? []) as T[],
+ },
+ prompts: { ask } as never,
+ notifications: { notify } as never,
+ executeCommand,
+ logger: logger as never,
+ local,
+ });
+ for (const method of [
+ 'createFile',
+ 'createFolder',
+ 'createFromTemplate',
+ 'rename',
+ 'move',
+ 'copy',
+ 'duplicate',
+ 'delete',
+ 'import',
+ ] as const)
+ vi.spyOn(service.operations, method).mockImplementation(async () => [] as never);
+ return { service, executeCommand, ask, notify, logger, contextKeys: contextKeys() };
+};
diff --git a/plugins/file-manager/test/fixtures/ui-mock.ts b/plugins/file-manager/test/fixtures/ui-mock.ts
new file mode 100644
index 00000000..85dae8ef
--- /dev/null
+++ b/plugins/file-manager/test/fixtures/ui-mock.ts
@@ -0,0 +1,9 @@
+import { vi } from 'vitest';
+
+/** `@nanoforge-dev/editor-sdk/ui` without its Svelte components, for tests in Node. */
+export const uiMock = () => ({
+ FILE_ICONS: { id: 'files.icons' },
+ FILE_TEMPLATES: { id: 'files.templates' },
+ FILE_ACTIONS: { id: 'files.actions' },
+ notifyError: vi.fn(),
+});
diff --git a/plugins/file-manager/test/menu/file-menu.spec.ts b/plugins/file-manager/test/menu/file-menu.spec.ts
new file mode 100644
index 00000000..fa4a3054
--- /dev/null
+++ b/plugins/file-manager/test/menu/file-menu.spec.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { fileMenu, newEntries } from '../../src/menu/file-menu';
+import { makeService } from '../fixtures/service';
+
+vi.mock('@nanoforge-dev/editor-sdk/ui', async () => (await import('../fixtures/ui-mock')).uiMock());
+
+type Entry = { kind: string; id?: string; label?: string; disabled?: boolean; items?: Entry[] };
+const ids = (entries: readonly Entry[]) =>
+ entries.flatMap((entry) => (entry.kind === 'item' ? [entry.id] : []));
+
+describe('file menu', () => {
+ it('offers to open, create, edit and download a file', () => {
+ const { service, contextKeys, executeCommand } = makeService();
+ const menu = fileMenu(
+ service,
+ ['src/main.ts'],
+ contextKeys,
+ { getValues: () => [] },
+ executeCommand,
+ );
+ expect(ids(menu)).toEqual([
+ 'open',
+ 'import',
+ 'cut',
+ 'copy',
+ 'paste',
+ 'duplicate',
+ 'rename',
+ 'delete',
+ 'reveal',
+ 'download',
+ 'copy-path',
+ ]);
+ expect(menu.find((entry) => entry.kind === 'item' && entry.id === 'paste')).toMatchObject({
+ disabled: true,
+ });
+ });
+
+ it('only reads installed packages', () => {
+ const { service, contextKeys, executeCommand } = makeService(undefined, undefined, {
+ local: false,
+ });
+ const menu = fileMenu(
+ service,
+ ['nf_modules/ball.ts'],
+ contextKeys,
+ { getValues: () => [] },
+ executeCommand,
+ );
+ expect(ids(menu)).toEqual(['open', 'cut', 'copy', 'paste', 'download', 'copy-path']);
+ expect(menu.find((entry) => entry.kind === 'item' && entry.id === 'cut')).toMatchObject({
+ disabled: true,
+ });
+ });
+
+ it('adds the contributed actions whose when clause holds, in group order', () => {
+ const { service, contextKeys, executeCommand } = makeService();
+ const actions = [
+ { id: 'b', title: 'Second', command: 'x.b', group: 'b', when: 'fileKind == file' },
+ { id: 'a', title: 'First', command: 'x.a', group: 'a', when: "fileExtname == '.ts'" },
+ { id: 'c', title: 'Folders only', command: 'x.c', when: 'fileKind == directory' },
+ ];
+ const menu = fileMenu(
+ service,
+ ['src/main.ts'],
+ contextKeys,
+ { getValues: () => actions as never[] },
+ executeCommand,
+ );
+ const labels = menu.flatMap((entry) => ('label' in entry ? [entry.label] : []));
+ expect(labels).toContain('First');
+ expect(labels).toContain('Second');
+ expect(labels).not.toContain('Folders only');
+ expect(labels.indexOf('First')).toBeLessThan(labels.indexOf('Second'));
+ });
+
+ it('groups the contributed templates by category under New', () => {
+ const { service } = makeService(undefined, {
+ 'files.templates': [
+ { id: 'component', title: 'Component', category: 'ECS', icon: 'box' },
+ { id: 'system', title: 'System', category: 'ECS' },
+ { id: 'readme', title: 'Readme' },
+ ],
+ });
+ const entries = newEntries(service, 'src') as Entry[];
+ expect(entries.map((entry) => entry.label ?? entry.kind)).toEqual([
+ 'File…',
+ 'Folder…',
+ 'separator',
+ 'Component…',
+ 'System…',
+ 'separator',
+ 'Readme…',
+ ]);
+ });
+});
diff --git a/plugins/file-manager/test/service/file-manager-service.spec.ts b/plugins/file-manager/test/service/file-manager-service.spec.ts
new file mode 100644
index 00000000..06c9ccad
--- /dev/null
+++ b/plugins/file-manager/test/service/file-manager-service.spec.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { makeService } from '../fixtures/service';
+
+vi.mock('@nanoforge-dev/editor-sdk/ui', async () => (await import('../fixtures/ui-mock')).uiMock());
+
+describe('FileManagerService', () => {
+ it('shows the folder of a selected file, the folder itself when one is selected', () => {
+ const { service } = makeService();
+ service.select(['src/main.ts']);
+ expect(service.folder.get()).toBe('src');
+ service.select(['src']);
+ expect(service.folder.get()).toBe('src');
+ expect(service.targetFolder('src/main.ts')).toBe('src');
+ service.select([]);
+ expect(service.targetFolder()).toBe('src');
+ });
+
+ it('opens a file in an editor and a folder in the grid', async () => {
+ const { service, executeCommand } = makeService();
+ await service.open('src/main.ts');
+ expect(executeCommand).toHaveBeenCalledWith('documents.open', 'src/main.ts');
+ await service.open('nf_modules');
+ expect(service.folder.get()).toBe('nf_modules');
+ await service.openWith('src/main.ts', { widget: 'hex', command: 'hex.open' } as never);
+ expect(executeCommand).toHaveBeenCalledWith('hex.open', 'src/main.ts');
+ await service.openWith('src/main.ts', { widget: 'preview' } as never);
+ expect(executeCommand).toHaveBeenCalledWith('workbench.openWidget', 'preview');
+ });
+
+ it('never cuts, moves, renames or deletes installed packages', async () => {
+ const { service } = makeService();
+ service.copyToClipboard('cut', ['nf_modules/ball.ts']);
+ expect(service.clipboard.get()).toBeUndefined();
+ service.copyToClipboard('copy', ['nf_modules/ball.ts']);
+ expect(service.clipboard.get()).toEqual({ mode: 'copy', paths: ['nf_modules/ball.ts'] });
+ await service.move(['nf_modules/ball.ts'], 'src');
+ await service.delete(['nf_modules/ball.ts']);
+ expect(await service.renameTo('nf_modules/ball.ts', 'x.ts')).toBe(false);
+ expect(service.operations.move).not.toHaveBeenCalled();
+ expect(service.operations.delete).not.toHaveBeenCalled();
+ expect(service.operations.rename).not.toHaveBeenCalled();
+ });
+
+ it('deletes one file at once, and asks before deleting a folder', async () => {
+ const { service } = makeService();
+ await service.delete(['src/main.ts']);
+ expect(service.operations.delete).toHaveBeenCalledWith(['src/main.ts']);
+ await service.delete(['src']);
+ expect(service.pendingDelete.get()).toEqual(['src']);
+ await service.confirmDelete(false);
+ expect(service.operations.delete).toHaveBeenCalledTimes(1);
+ await service.delete(['src']);
+ await service.confirmDelete(true);
+ expect(service.operations.delete).toHaveBeenLastCalledWith(['src']);
+ expect(service.pendingDelete.get()).toBeUndefined();
+ });
+
+ it('pastes a cut once, then empties the clipboard', async () => {
+ const { service } = makeService();
+ service.copyToClipboard('cut', ['src/main.ts']);
+ await service.paste('');
+ expect(service.operations.move).toHaveBeenCalledWith(['src/main.ts'], '');
+ expect(service.clipboard.get()).toBeUndefined();
+ service.copyToClipboard('copy', ['src/main.ts']);
+ await service.paste('src');
+ expect(service.operations.copy).toHaveBeenCalledWith(['src/main.ts'], 'src');
+ expect(service.clipboard.get()).toBeDefined();
+ });
+
+ it('asks a name, refusing one already taken, then creates and opens the file', async () => {
+ const { service, ask, executeCommand } = makeService();
+ vi.mocked(service.operations.createFile).mockResolvedValue('src/new.ts');
+ ask.mockImplementation(async ({ validate }) => {
+ expect(validate?.('main.ts')).toBe('main.ts already exists');
+ return 'new.ts';
+ });
+ await service.newFile('src');
+ expect(service.operations.createFile).toHaveBeenCalledWith('src', 'new.ts');
+ expect(executeCommand).toHaveBeenCalledWith('documents.open', 'src/new.ts', { line: 1 });
+ });
+
+ it('reports a failed operation instead of throwing', async () => {
+ const { service, logger } = makeService();
+ vi.mocked(service.operations.duplicate).mockRejectedValue(new Error('disk full'));
+ await service.duplicate(['src/main.ts']);
+ expect(logger.warn).toHaveBeenCalledWith('A file operation failed', expect.any(Error));
+ expect(await service.renameTo('src/main.ts', 'a/b')).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/cannot contain/));
+ expect(await service.renameTo('src/main.ts', '..')).toBe(false);
+ expect(await service.renameTo('src/main.ts', ' ')).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith('Enter a name');
+ });
+});
diff --git a/plugins/file-manager/test/widgets/files/file-tree.spec.ts b/plugins/file-manager/test/widgets/files/file-tree.spec.ts
new file mode 100644
index 00000000..62e1013b
--- /dev/null
+++ b/plugins/file-manager/test/widgets/files/file-tree.spec.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+
+import type { FileEntry } from '@nanoforge-dev/editor-sdk';
+
+import { FileFilter } from '../../../src/filter/file-filter';
+import {
+ type FileTreeContext,
+ buildTree,
+ gridOrder,
+ openFolders,
+ searchTree,
+ treeNode,
+} from '../../../src/widgets/files/file-tree';
+
+const ENTRIES: FileEntry[] = [
+ { path: 'src', kind: 'directory' },
+ { path: 'src/main.ts', kind: 'file' },
+ { path: 'src/systems', kind: 'directory' },
+ { path: 'src/systems/move.ts', kind: 'file' },
+ { path: 'debug.log', kind: 'file' },
+ { path: 'nf_modules', kind: 'directory' },
+ { path: 'nf_modules/ball.ts', kind: 'file' },
+] as FileEntry[];
+
+const dirname = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '');
+const context = (overrides: Partial = {}): FileTreeContext => ({
+ service: {
+ project: {
+ fs: { children: (path: string) => ENTRIES.filter((entry) => dirname(entry.path) === path) },
+ },
+ iconOf: (entry: FileEntry, expanded: boolean) =>
+ entry.kind === 'directory' ? (expanded ? 'folder-open' : 'folder') : 'file',
+ } as never,
+ filter: new FileFilter(['*.log']),
+ showHidden: false,
+ expanded: new Set(),
+ decorate: () => undefined,
+ ...overrides,
+});
+
+describe('file tree', () => {
+ it('leaves out hidden files and the packages folder at the root', () => {
+ expect(buildTree(context(), '').map((node) => node.id)).toEqual(['src']);
+ const all = buildTree(context({ showHidden: true }), '');
+ expect(all.map((node) => node.id)).toEqual(['src', 'debug.log']);
+ expect(all.find((node) => node.id === 'debug.log')?.detail).toBe('hidden');
+ expect(buildTree(context(), '')[0]?.children?.map((node) => node.id)).toEqual([
+ 'src/main.ts',
+ 'src/systems',
+ ]);
+ });
+
+ it('marks packages read-only, shows decorations and open folders', () => {
+ const packages = treeNode(context(), { path: 'nf_modules', kind: 'directory' } as FileEntry);
+ expect(packages).toMatchObject({ icon: 'lock', draggable: false, droppable: false });
+ const decorated = treeNode(
+ context({
+ expanded: new Set(['src']),
+ decorate: () => ({ badge: 'M', tone: 'warning', tooltip: 'Modified' }),
+ }),
+ { path: 'src', kind: 'directory' } as FileEntry,
+ );
+ expect(decorated).toMatchObject({
+ icon: 'folder-open',
+ detail: 'M',
+ tone: 'warning',
+ detailTitle: 'Modified',
+ droppable: true,
+ children: [],
+ });
+ });
+
+ it('searches inside folders, and opens the folders holding a match', () => {
+ const found = searchTree(context(), '', 'move');
+ expect(found.map((node) => node.id)).toEqual(['src']);
+ expect([...openFolders(found)]).toEqual(['src', 'src/systems']);
+ expect(searchTree(context(), '', 'zzz')).toEqual([]);
+ });
+
+ it('puts folders first in the grid, then sorts by path', () => {
+ const order = gridOrder([
+ { path: 'b.ts', kind: 'file' },
+ { path: 'z', kind: 'directory' },
+ { path: 'a.ts', kind: 'file' },
+ ] as FileEntry[]);
+ expect(order.map((entry) => entry.path)).toEqual(['z', 'a.ts', 'b.ts']);
+ });
+});
diff --git a/plugins/git/package.json b/plugins/git/package.json
index 6c761198..3bc4f6bc 100644
--- a/plugins/git/package.json
+++ b/plugins/git/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/history-panel/package.json b/plugins/history-panel/package.json
index cd06518f..b6146730 100644
--- a/plugins/history-panel/package.json
+++ b/plugins/history-panel/package.json
@@ -34,6 +34,8 @@
"dev": "vite build --watch",
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
@@ -43,7 +45,8 @@
"svelte": "catalog:core",
"svelte-check": "catalog:core",
"typescript": "catalog:build",
- "vite": "catalog:core"
+ "vite": "catalog:core",
+ "vitest": "catalog:test"
},
"packageManager": "pnpm@12.0.0",
"engines": {
diff --git a/plugins/history-panel/test/widgets/history/relative-time.spec.ts b/plugins/history-panel/test/widgets/history/relative-time.spec.ts
new file mode 100644
index 00000000..bd3a6772
--- /dev/null
+++ b/plugins/history-panel/test/widgets/history/relative-time.spec.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+
+import { relativeTime } from '../../../src/widgets/history/relative-time';
+
+describe('relativeTime', () => {
+ const now = Date.UTC(2026, 9, 13, 12);
+ it('says just now, then minutes, then hours, then the date', () => {
+ expect(relativeTime(now - 10_000, now)).toBe('just now');
+ expect(relativeTime(now + 5_000, now)).toBe('just now');
+ expect(relativeTime(now - 40_000, now)).toBe('1 min ago');
+ expect(relativeTime(now - 25 * 60_000, now)).toBe('25 min ago');
+ expect(relativeTime(now - 3 * 3_600_000, now)).toBe('3 h ago');
+ expect(relativeTime(now - 3 * 86_400_000, now)).toBe(
+ new Date(now - 3 * 86_400_000).toLocaleDateString(),
+ );
+ });
+});
diff --git a/plugins/inspectors/package.json b/plugins/inspectors/package.json
index 3012ed93..42802ce4 100644
--- a/plugins/inspectors/package.json
+++ b/plugins/inspectors/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/packages/package.json b/plugins/packages/package.json
index ae54db95..ae5f3389 100644
--- a/plugins/packages/package.json
+++ b/plugins/packages/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/scene/package.json b/plugins/scene/package.json
index 8fb5f686..5b213ecc 100644
--- a/plugins/scene/package.json
+++ b/plugins/scene/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/scene/src/extension/ecs-source.extension-point.ts b/plugins/scene/src/extension/ecs-source.extension-point.ts
index 6803c9f1..8c973b98 100644
--- a/plugins/scene/src/extension/ecs-source.extension-point.ts
+++ b/plugins/scene/src/extension/ecs-source.extension-point.ts
@@ -4,7 +4,7 @@
*/
import { type AppModel, type Observable, defineExtensionPoint } from '@nanoforge-dev/editor-sdk';
-export type EntryScope =
+type EntryScope =
| { readonly kind: 'main' }
| { readonly kind: 'method'; readonly class: string; readonly method: string };
diff --git a/plugins/scene/src/service/scene-service.ts b/plugins/scene/src/service/scene-service.ts
index cd423a16..3fb5a994 100644
--- a/plugins/scene/src/service/scene-service.ts
+++ b/plugins/scene/src/service/scene-service.ts
@@ -55,7 +55,7 @@ export const sceneApps = (model: ProjectModel | undefined): AppModel[] =>
);
/** Where a scene's entities are: its class's `setup`. */
-export const locationOf = (scene: SceneModel): EcsLocation => ({
+const locationOf = (scene: SceneModel): EcsLocation => ({
path: scene.path,
scope: { kind: 'method', class: scene.className, method: 'setup' },
label: scene.id,
@@ -66,11 +66,11 @@ export const locationOf = (scene: SceneModel): EcsLocation => ({
export const MAIN_LABEL = 'main.ts';
/** Where the app's own entities are: its entry file's `main`. */
-export const mainLocation = (app: AppModel): EcsLocation | undefined =>
+const mainLocation = (app: AppModel): EcsLocation | undefined =>
app.entryFile ? { path: app.entryFile, label: MAIN_LABEL } : undefined;
/** A scene's parents, outermost first (stops at a cycle or an unknown parent). */
-export const parentsOf = (scenes: readonly SceneModel[], scene: SceneModel): SceneModel[] => {
+const parentsOf = (scenes: readonly SceneModel[], scene: SceneModel): SceneModel[] => {
const chain: SceneModel[] = [];
let parent = scenes.find((candidate) => candidate.className === scene.parent);
while (parent && !chain.includes(parent) && parent !== scene) {
diff --git a/plugins/scene/src/template/scene-template.ts b/plugins/scene/src/template/scene-template.ts
index f50b9909..338db6d6 100644
--- a/plugins/scene/src/template/scene-template.ts
+++ b/plugins/scene/src/template/scene-template.ts
@@ -23,7 +23,7 @@ export const scenePath = (app: AppModel, name: string): string =>
`${app.dirs.scenes}/${kebab(name)}.ts`;
/** The import of a file from another one in the same app (relative, no extension). */
-export const relativeImport = (from: string, to: string): string => {
+const relativeImport = (from: string, to: string): string => {
const source = from.split('/').slice(0, -1);
const target = to.replace(/\.[cm]?[jt]sx?$/, '').split('/');
let common = 0;
diff --git a/plugins/scene/src/widgets/scenes/scene-tree.ts b/plugins/scene/src/widgets/scenes/scene-tree.ts
index ec5e893f..0c18702d 100644
--- a/plugins/scene/src/widgets/scenes/scene-tree.ts
+++ b/plugins/scene/src/widgets/scenes/scene-tree.ts
@@ -6,7 +6,7 @@ import type { AppScenesModel, SceneModel } from '../../model/scene-model.type';
export const MAIN_ID = '#main';
/** What a scene's row says of it: initial, its params, what it lacks. */
-export const sceneDetail = (model: AppScenesModel | undefined, scene: SceneModel): string =>
+const sceneDetail = (model: AppScenesModel | undefined, scene: SceneModel): string =>
[
scene.className === model?.library?.initial ? 'initial' : '',
scene.paramsType ?? '',
diff --git a/plugins/scene/src/worker/main-to-scene.ts b/plugins/scene/src/worker/main-to-scene.ts
index 3cee62b9..862e8e80 100644
--- a/plugins/scene/src/worker/main-to-scene.ts
+++ b/plugins/scene/src/worker/main-to-scene.ts
@@ -7,7 +7,7 @@ import { projectPath } from './scenes';
import { applyEdits, dedent, ownStart, removalStart, trailingEnd } from './source-edits';
/** Where the scene goes, and its class. */
-export interface MainToSceneArgs {
+interface MainToSceneArgs {
readonly scenePath: string;
readonly className: string;
readonly side: 'client' | 'server';
diff --git a/plugins/scene/src/worker/scenes.ts b/plugins/scene/src/worker/scenes.ts
index 8c01fca7..6b57a996 100644
--- a/plugins/scene/src/worker/scenes.ts
+++ b/plugins/scene/src/worker/scenes.ts
@@ -52,14 +52,14 @@ const importOf = (file: SourceFile, local: string): ImportedName | undefined =>
};
/** A class declaration a name refers to: in the same file, or imported from a project file. */
-export const classNamed = (file: SourceFile, local: string): ClassDeclaration | undefined => {
+const classNamed = (file: SourceFile, local: string): ClassDeclaration | undefined => {
const own = file.getClass(local);
if (own) return own;
const imported = importOf(file, local);
return imported?.file?.getClass(imported.name);
};
-export interface SceneBase {
+interface SceneBase {
readonly ecs: boolean;
/** The type argument given to `Scene` / `EcsScene`, if any. */
readonly params?: TypeNode;
@@ -68,7 +68,7 @@ export interface SceneBase {
}
/** Whether a class is a scene: it extends `Scene` or `EcsScene`, through project classes. */
-export const sceneBase = (
+const sceneBase = (
declaration: ClassDeclaration,
seen = new Set(),
): SceneBase | undefined => {
diff --git a/plugins/scene/src/worker/vars.ts b/plugins/scene/src/worker/vars.ts
index de2c257c..a9401445 100644
--- a/plugins/scene/src/worker/vars.ts
+++ b/plugins/scene/src/worker/vars.ts
@@ -25,7 +25,7 @@ const METHODS = new Set(['init', 'set', 'get', 'has', 'remove']);
const MODULE = '@nanoforge-dev/scene';
/** `interface SceneVars` of a `declare module "@nanoforge-dev/scene"` block of a file. */
-export const sceneVarsOf = (file: SourceFile): InterfaceDeclaration | undefined => {
+const sceneVarsOf = (file: SourceFile): InterfaceDeclaration | undefined => {
for (const module of file.getModules()) {
const name = module.getName().replace(/^['"]|['"]$/g, '');
if (name === MODULE) return module.getInterface('SceneVars');
diff --git a/plugins/scene/test/service/scene-service.spec.ts b/plugins/scene/test/service/scene-service.spec.ts
new file mode 100644
index 00000000..38d65f2b
--- /dev/null
+++ b/plugins/scene/test/service/scene-service.spec.ts
@@ -0,0 +1,196 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ CodeServiceToken,
+ DocumentServiceToken,
+ ObservableValue,
+ ProjectServiceToken,
+} from '@nanoforge-dev/editor-sdk';
+
+import type { AppScenesModel, SceneModel } from '../../src/model/scene-model.type';
+import { SceneService } from '../../src/service/scene-service';
+
+const { NOTIFICATIONS } = vi.hoisted(() => ({ NOTIFICATIONS: { id: 'notifications' } }));
+vi.mock('@nanoforge-dev/editor-sdk/ui', () => ({ NotificationServiceToken: NOTIFICATIONS }));
+
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+const scene = (className: string, parent?: string): SceneModel =>
+ ({
+ id: className,
+ className,
+ path: `apps/client/src/scenes/${className.toLowerCase()}.ts`,
+ line: 3,
+ ...(parent && { parent }),
+ ecs: true,
+ ownSetup: true,
+ }) as SceneModel;
+
+const CLIENT = {
+ id: 'client',
+ name: 'client',
+ type: 'client',
+ root: 'apps/client',
+ entryFile: 'apps/client/src/main.ts',
+ engineLibs: { '@nanoforge-dev/scene': '*' },
+ dirs: { scenes: 'apps/client/src/scenes' },
+};
+const SERVER = { ...CLIENT, id: 'server', name: 'server', type: 'server', root: 'apps/server' };
+const PLAIN = { ...CLIENT, id: 'plain', engineLibs: {} };
+
+const SCENES: AppScenesModel = {
+ scenes: [scene('Menu'), scene('Game'), scene('Pause', 'Game')],
+ library: { hasScenes: true } as never,
+ problems: [],
+};
+
+const setup = ({ analyze }: { analyze?: (analyzer: string) => Promise } = {}) => {
+ const files = new Map([['apps/client/src/scenes/menu.ts', '']]);
+ const projectModel = new ObservableValue({ apps: [PLAIN, SERVER, CLIENT] } as never);
+ const project = {
+ model: projectModel,
+ fs: {
+ entry: (path: string) => (files.has(path) ? { path, kind: 'file' } : undefined),
+ write: vi.fn(async (path: string, text: string) => void files.set(path, text)),
+ delete: vi.fn(async (path: string) => void files.delete(path)),
+ onDidChange: () => ({ dispose: () => undefined }),
+ },
+ };
+ const edit = vi.fn(async (path: string, transformer: string, op: unknown) => ({
+ label: transformer,
+ do: async () => void files.set(path, JSON.stringify(op)),
+ undo: async () => undefined,
+ }));
+ const code = {
+ analyze: vi.fn(async (_path: string, analyzer: string) =>
+ analyze ? analyze(analyzer) : analyzer.includes('vars') ? { vars: [] } : SCENES,
+ ),
+ edit,
+ };
+ const documents = { onDidChange: () => ({ dispose: () => undefined }), save: vi.fn() };
+ const notify = vi.fn();
+ const services = new Map([
+ [ProjectServiceToken, { current: new ObservableValue(project) }],
+ [CodeServiceToken, code],
+ [DocumentServiceToken, documents],
+ [NOTIFICATIONS, { notify }],
+ ]);
+ const keys = new Map();
+ const executeCommand = vi.fn(async () => undefined);
+ const context = {
+ name: '@nanoforge/scene',
+ services: {
+ get: (token: unknown) => services.get(token),
+ tryGet: (token: unknown) => services.get(token),
+ },
+ scope: { observe: () => new ObservableValue(code) },
+ contextKeys: {
+ get: (key: string) => keys.get(key),
+ onDidChange: () => ({ dispose: () => undefined }),
+ },
+ logger: { warn: vi.fn() },
+ executeCommand,
+ };
+ const service = new SceneService(context as never);
+ return { service, code, files, notify, executeCommand, context };
+};
+
+afterEach(() => vi.useRealTimers());
+
+describe('SceneService', () => {
+ it('reads the scenes of the client app that has the scene library', async () => {
+ const { service, code } = setup();
+ await flush();
+ await flush();
+ expect(service.apps.map((app) => app.id)).toEqual(['server', 'client']);
+ expect(service.appId.get()).toBe('client');
+ expect(service.model.get()?.scenes.map((model) => model.id)).toEqual(['Menu', 'Game', 'Pause']);
+ expect(service.vars.get()).toEqual({ vars: [] });
+ expect(code.analyze).toHaveBeenCalledWith('apps/client/src/main.ts', expect.any(String), {
+ root: 'apps/client',
+ });
+ service.dispose();
+ });
+
+ it("edits main until a scene is selected, then its setup under its parents' read-only ones", async () => {
+ const { service } = setup();
+ await flush();
+ await flush();
+ const app = service.app!;
+ const current = service.current(app);
+ const seen: unknown[] = [];
+ const stop = current.subscribe((value) => seen.push(value));
+ expect(current.get()).toEqual({ path: 'apps/client/src/main.ts', label: 'main.ts' });
+ service.select('Pause');
+ expect(current.get()).toMatchObject({
+ path: 'apps/client/src/scenes/pause.ts',
+ scope: { kind: 'method', class: 'Pause', method: 'setup' },
+ });
+ expect(
+ service
+ .inherited(app)
+ .get()
+ .map((location) => location.label),
+ ).toEqual(['main.ts', 'Game']);
+ service.select('Pause');
+ expect(seen).toHaveLength(1);
+ stop();
+ expect(service.sceneLocation(app, 'Game')?.path).toBe('apps/client/src/scenes/game.ts');
+ expect(service.sceneLocation(app, 'Nope')).toBeUndefined();
+ service.select(undefined);
+ expect(service.selectedScene(app)).toBeUndefined();
+ });
+
+ it('creates a scene file, adds it to the library, selects and opens it', async () => {
+ const { service, files, code, executeCommand } = setup();
+ await flush();
+ await flush();
+ expect(await service.newScene('Victory', SCENES.scenes[1])).toBe(true);
+ expect(files.get('apps/client/src/scenes/victory.ts')).toContain('class Victory');
+ expect(code.edit).toHaveBeenCalledWith(
+ 'apps/client/src/main.ts',
+ expect.any(String),
+ expect.objectContaining({ kind: 'addScene', className: 'Victory' }),
+ { label: 'Scenes' },
+ );
+ expect(service.selected.get().get('client')).toBe('Victory');
+ expect(executeCommand).toHaveBeenCalledWith(
+ 'codeEditor.open',
+ 'apps/client/src/scenes/victory.ts',
+ { line: 11 },
+ );
+ });
+
+ it('refuses a scene whose file exists, and a parent inside the scene', async () => {
+ const { service, notify } = setup();
+ await flush();
+ await flush();
+ expect(await service.newScene('Menu')).toBe(false);
+ expect(notify).toHaveBeenCalledWith('warning', 'New scene Menu: not possible', {
+ detail: 'apps/client/src/scenes/menu.ts already exists.',
+ });
+ const [, game, pause] = SCENES.scenes;
+ expect(await service.setParent(game!, pause)).toBe(false);
+ expect(notify).toHaveBeenLastCalledWith('warning', 'Move Game: not possible', {
+ detail: 'Pause is inside Game.',
+ });
+ expect(await service.setParent(pause!, undefined)).toBe(true);
+ expect(await service.setInitial(game!)).toBe(true);
+ });
+
+ it('waits for the worker to register its analyzers, then says what went wrong', async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const { service, context } = setup({
+ analyze: async () => {
+ calls++;
+ throw new Error(calls < 3 ? 'Unknown analyzer scenes' : 'Broken entry file');
+ },
+ });
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(calls).toBe(3);
+ expect(service.model.get()).toEqual({ scenes: [], problems: ['Broken entry file'] });
+ expect(context.logger.warn).toHaveBeenCalled();
+ service.dispose();
+ });
+});
diff --git a/plugins/settings-ui/package.json b/plugins/settings-ui/package.json
index 43af452e..fef0394d 100644
--- a/plugins/settings-ui/package.json
+++ b/plugins/settings-ui/package.json
@@ -35,6 +35,7 @@
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
"test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
diff --git a/plugins/settings-ui/src/settings/setting-categories.ts b/plugins/settings-ui/src/settings/setting-categories.ts
index 97c11111..aeacfd7a 100644
--- a/plugins/settings-ui/src/settings/setting-categories.ts
+++ b/plugins/settings-ui/src/settings/setting-categories.ts
@@ -8,7 +8,7 @@ export const SCOPE_LABELS: Record = {
projectLocal: 'Project (only me)',
};
-export const OTHER_CATEGORY = 'Other';
+const OTHER_CATEGORY = 'Other';
/** A node of the category tree: `Editor`, `Editor/Code editor`… */
export interface CategoryNode {
@@ -17,8 +17,7 @@ export interface CategoryNode {
readonly children: CategoryNode[];
}
-export const categoryOf = (definition: SettingDefinition): string =>
- definition.category ?? OTHER_CATEGORY;
+const categoryOf = (definition: SettingDefinition): string => definition.category ?? OTHER_CATEGORY;
/** Category tree of every setting (paths split on `/`). */
export const categoryTree = (definitions: readonly SettingDefinition[]): CategoryNode[] => {
diff --git a/plugins/settings-ui/test/marketplace/account-plugins.spec.ts b/plugins/settings-ui/test/marketplace/account-plugins.spec.ts
new file mode 100644
index 00000000..4a47dc6a
--- /dev/null
+++ b/plugins/settings-ui/test/marketplace/account-plugins.spec.ts
@@ -0,0 +1,103 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { RegistryContract, SessionContract } from '@nanoforge-dev/editor-sdk';
+
+import { followAccountPlugins } from '../../src/marketplace/account-plugins';
+import { ACCOUNT_PLUGINS } from '../../src/marketplace/marketplace';
+
+type Action = { title: string; run: () => unknown };
+
+const setup = ({ mode = 'OFFLINE', failing = [] as string[] } = {}) => {
+ let list: Record = {
+ '@acme/a': '^1.0.0',
+ '@acme/b': '^1.0.0',
+ '@acme/c': '^1.0.0',
+ };
+ const listeners = new Set<(event: { keys: Set }) => void>();
+ const settings = {
+ get: () => list,
+ set: vi.fn(async (_key: string, value: Record) => {
+ list = value;
+ }),
+ onDidChange: (listener: (event: { keys: Set }) => void) => {
+ listeners.add(listener);
+ return { dispose: () => listeners.delete(listener) };
+ },
+ };
+ const installPlugin = vi.fn(async ({ name }: { name: string }) => {
+ if (failing.includes(name)) throw new Error('not in the registry');
+ });
+ const rpc = {
+ api: (contract: unknown) =>
+ contract === RegistryContract
+ ? { installPlugin }
+ : contract === SessionContract
+ ? { info: async () => ({ mode }) }
+ : undefined,
+ };
+ const host = {
+ plugins: {
+ get: () => [
+ { name: '@acme/a', descriptor: { manifest: { version: '1.0.0' }, source: 'installed' } },
+ ],
+ },
+ };
+ const notify = vi.fn();
+ const follow = followAccountPlugins({
+ rpc: rpc as never,
+ settings: settings as never,
+ host: host as never,
+ notifications: { notify } as never,
+ delay: 10,
+ });
+ const actions = (call: number) => (notify.mock.calls[call]?.[2] as { actions: Action[] }).actions;
+ const changeList = (next: Record) => {
+ list = next;
+ for (const listener of listeners) listener({ keys: new Set([ACCOUNT_PLUGINS]) });
+ };
+ return { follow, notify, installPlugin, settings, actions, changeList };
+};
+
+afterEach(() => vi.useRealTimers());
+
+describe('followAccountPlugins', () => {
+ it('names the plugins of the account missing here once, and installs them', async () => {
+ vi.useFakeTimers();
+ const { notify, installPlugin, actions, changeList, follow } = setup({ failing: ['@acme/c'] });
+ await vi.advanceTimersByTimeAsync(10);
+ expect(notify).toHaveBeenCalledTimes(1);
+ expect(notify.mock.calls[0]?.[1]).toBe('2 plugins of your account are not installed here');
+ changeList({ '@acme/b': '^1.0.0', '@acme/c': '^1.0.0' });
+ expect(notify).toHaveBeenCalledTimes(1);
+ await actions(0)
+ .find((action) => action.title === 'Install')!
+ .run();
+ expect(installPlugin.mock.calls.map(([call]) => call)).toEqual([
+ { name: '@acme/b', scope: 'user' },
+ { name: '@acme/c', scope: 'user' },
+ ]);
+ expect(notify.mock.calls.map((call) => call[1])).toEqual([
+ '2 plugins of your account are not installed here',
+ 'Some plugins could not be installed',
+ 'Plugins installed: reload the editor to finish',
+ ]);
+ follow.dispose();
+ });
+
+ it('removes them from the account when asked', async () => {
+ vi.useFakeTimers();
+ const { actions, settings } = setup();
+ await vi.advanceTimersByTimeAsync(10);
+ await actions(0)
+ .find((action) => action.title === 'Remove from my account')!
+ .run();
+ expect(settings.set).toHaveBeenCalledWith(ACCOUNT_PLUGINS, { '@acme/a': '^1.0.0' }, 'account');
+ });
+
+ it('offers nothing in a hosted editor', async () => {
+ vi.useFakeTimers();
+ const { notify } = setup({ mode: 'ONLINE' });
+ await vi.advanceTimersByTimeAsync(100);
+ expect(notify).not.toHaveBeenCalled();
+ });
+});
diff --git a/plugins/settings-ui/test/settings/setting-categories.spec.ts b/plugins/settings-ui/test/settings/setting-categories.spec.ts
new file mode 100644
index 00000000..4714a999
--- /dev/null
+++ b/plugins/settings-ui/test/settings/setting-categories.spec.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+
+import type { SettingDefinition } from '@nanoforge-dev/editor-sdk';
+
+import { categoryTree, searchSettings, settingsIn } from '../../src/settings/setting-categories';
+
+const setting = (key: string, extra: Partial = {}) =>
+ ({ key, title: key, description: '', tags: [], ...extra }) as SettingDefinition;
+
+const SETTINGS = [
+ setting('editor.fontSize', { category: 'Editor', title: 'Font size', order: 2 }),
+ setting('editor.tabSize', { category: 'Editor', title: 'Tab size', order: 1 }),
+ setting('code.format', {
+ category: 'Editor/Code editor',
+ title: 'Format on save',
+ tags: ['prettier'],
+ }),
+ setting('misc.thing'),
+];
+
+describe('setting categories', () => {
+ it('builds the category tree from slash paths, sorted, with Other for the rest', () => {
+ expect(categoryTree(SETTINGS)).toEqual([
+ {
+ id: 'Editor',
+ label: 'Editor',
+ children: [{ id: 'Editor/Code editor', label: 'Code editor', children: [] }],
+ },
+ { id: 'Other', label: 'Other', children: [] },
+ ]);
+ });
+
+ it('lists a category with its subcategories, by category then order', () => {
+ expect(settingsIn(SETTINGS, 'Editor').map((definition) => definition.key)).toEqual([
+ 'editor.tabSize',
+ 'editor.fontSize',
+ 'code.format',
+ ]);
+ expect(settingsIn(SETTINGS, 'Other').map((definition) => definition.key)).toEqual([
+ 'misc.thing',
+ ]);
+ });
+
+ it('finds settings holding every word of the query, tags and category included', () => {
+ expect(searchSettings(SETTINGS, 'PRETTIER save').map((definition) => definition.key)).toEqual([
+ 'code.format',
+ ]);
+ expect(searchSettings(SETTINGS, 'editor size')).toHaveLength(2);
+ expect(searchSettings(SETTINGS, ' ')).toEqual([]);
+ });
+});
diff --git a/plugins/settings-ui/test/settings/setting-control.spec.ts b/plugins/settings-ui/test/settings/setting-control.spec.ts
new file mode 100644
index 00000000..b7fc3158
--- /dev/null
+++ b/plugins/settings-ui/test/settings/setting-control.spec.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import type { SettingDefinition } from '@nanoforge-dev/editor-sdk';
+
+import { controlOf, defaultScope } from '../../src/settings/setting-control';
+
+vi.mock('@nanoforge-dev/editor-sdk', async (original) => ({
+ ...(await original()),
+ /** The tests give their JSON schema as the setting's schema. */
+ jsonSchemaOf: (definition: { schema: unknown }) => definition.schema,
+}));
+
+const definition = (schema: unknown, scopes: string[] = ['account', 'machine']) =>
+ ({ key: 'x.y', schema, scopes }) as unknown as SettingDefinition;
+
+describe('controlOf', () => {
+ it('picks the control the schema calls for', () => {
+ expect(controlOf(definition({ type: 'boolean' }))).toEqual({ kind: 'boolean' });
+ expect(controlOf(definition({ type: 'string', enum: ['a', 'b'] }))).toEqual({
+ kind: 'enum',
+ options: ['a', 'b'],
+ });
+ expect(controlOf(definition({ type: 'integer', minimum: 1, maximum: 9 }))).toEqual({
+ kind: 'number',
+ integer: true,
+ min: 1,
+ max: 9,
+ });
+ expect(controlOf(definition({ type: 'number' }))).toEqual({ kind: 'number', integer: false });
+ expect(controlOf(definition({ type: 'string' }))).toEqual({ kind: 'string' });
+ expect(controlOf(definition({ type: 'array', items: { type: 'string' } }))).toEqual({
+ kind: 'lines',
+ });
+ expect(controlOf(definition({ type: 'array', items: { type: 'number' } }))).toEqual({
+ kind: 'json',
+ });
+ expect(controlOf(definition({ type: 'object' }))).toEqual({ kind: 'json' });
+ });
+});
+
+describe('defaultScope', () => {
+ const settings = (effectiveScope: string, stores: string[]) =>
+ ({
+ inspect: () => ({ effectiveScope }),
+ hasStore: (scope: string) => stores.includes(scope),
+ }) as never;
+
+ it('writes where the value comes from, else the account, else this machine', () => {
+ const both = definition({}, ['account', 'machine', 'project']);
+ expect(defaultScope(settings('project', ['account', 'machine', 'project']), both)).toBe(
+ 'project',
+ );
+ expect(defaultScope(settings('default', ['account', 'machine']), both)).toBe('account');
+ expect(defaultScope(settings('default', ['machine']), both)).toBe('machine');
+ expect(defaultScope(settings('project', ['machine']), both)).toBe('machine');
+ expect(defaultScope(settings('default', []), definition({}, ['projectLocal']))).toBe(
+ 'projectLocal',
+ );
+ });
+});
diff --git a/plugins/viewport/package.json b/plugins/viewport/package.json
index 63e1a43f..69b71290 100644
--- a/plugins/viewport/package.json
+++ b/plugins/viewport/package.json
@@ -34,6 +34,8 @@
"dev": "vite build --watch",
"lint": "prettier --check . && eslint --format=pretty .",
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
+ "test:unit": "vitest run --config ../../vitest.config.ts",
+ "test:coverage": "vitest run --config ../../vitest.config.ts --coverage",
"format": "prettier --write . && eslint --fix --format=pretty src"
},
"devDependencies": {
@@ -44,7 +46,8 @@
"svelte": "catalog:core",
"svelte-check": "catalog:core",
"typescript": "catalog:build",
- "vite": "catalog:core"
+ "vite": "catalog:core",
+ "vitest": "catalog:test"
},
"packageManager": "pnpm@12.0.0",
"engines": {
diff --git a/plugins/viewport/src/game/resolutions.ts b/plugins/viewport/src/game/resolutions.ts
index 25b6d643..98a020c5 100644
--- a/plugins/viewport/src/game/resolutions.ts
+++ b/plugins/viewport/src/game/resolutions.ts
@@ -15,7 +15,7 @@ export const RESOLUTIONS: readonly Resolution[] = [
{ id: 'tablet', label: 'Tablet (820 × 1180)', width: 1180, height: 820, device: true },
];
-export const ZOOMS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4] as const;
+const ZOOMS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4] as const;
/**
* Size of the game container: undefined to fill the screen (`fit`), else the preset or custom
diff --git a/plugins/viewport/test/game/resolutions.spec.ts b/plugins/viewport/test/game/resolutions.spec.ts
new file mode 100644
index 00000000..446ba7c4
--- /dev/null
+++ b/plugins/viewport/test/game/resolutions.spec.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+
+import { frameSize, stepZoom } from '../../src/game/resolutions';
+
+describe('frameSize', () => {
+ it('fills the screen, or takes a preset or a custom size times the zoom', () => {
+ expect(frameSize('fit', 'landscape', 1)).toBeUndefined();
+ expect(frameSize('720p', 'landscape', 0.5)).toEqual({ width: 640, height: 360 });
+ expect(frameSize('800x600', 'portrait', 2)).toEqual({ width: 1600, height: 1200 });
+ expect(frameSize('8x6', 'landscape', 1)).toBeUndefined();
+ expect(frameSize('nonsense', 'landscape', 1)).toBeUndefined();
+ });
+
+ it('turns devices in portrait, not screens', () => {
+ expect(frameSize('phone', 'portrait', 1)).toEqual({ width: 390, height: 844 });
+ expect(frameSize('phone', 'landscape', 1)).toEqual({ width: 844, height: 390 });
+ expect(frameSize('1080p', 'portrait', 1)).toEqual({ width: 1920, height: 1080 });
+ });
+});
+
+describe('stepZoom', () => {
+ it('moves one step, stopping at the ends', () => {
+ expect(stepZoom(1, 1, false)).toBe(1.5);
+ expect(stepZoom(1, -1, false)).toBe(0.75);
+ expect(stepZoom(0.25, -1, false)).toBe(0.25);
+ expect(stepZoom(4, 1, false)).toBe(4);
+ expect(stepZoom(1.2, 1, false)).toBe(2);
+ });
+
+ it('keeps to whole zooms when pixel perfect', () => {
+ expect(stepZoom(1, 1, true)).toBe(2);
+ expect(stepZoom(0.5, 1, true)).toBe(2);
+ expect(stepZoom(3, -1, true)).toBe(2);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f4d139da..718f6d89 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -226,6 +226,9 @@ catalogs:
globals:
specifier: ^17.7.0
version: 17.12.0
+ knip:
+ specifier: ^6.38.0
+ version: 6.41.0
prettier:
specifier: ^3.9.4
version: 3.9.9
@@ -309,6 +312,9 @@ importers:
husky:
specifier: catalog:ci
version: 9.1.7
+ knip:
+ specifier: catalog:lint
+ version: 6.41.0
lint-staged:
specifier: catalog:ci
version: 17.5.1
@@ -765,9 +771,6 @@ importers:
prettier:
specifier: catalog:server
version: 3.9.9
- semver:
- specifier: catalog:libs
- version: 7.8.5
typescript:
specifier: catalog:build
version: 6.0.3
@@ -787,9 +790,6 @@ importers:
'@types/node':
specifier: catalog:core
version: 26.6.2
- '@types/semver':
- specifier: catalog:libs
- version: 7.8.0
vitest:
specifier: catalog:test
version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))
@@ -1095,6 +1095,9 @@ importers:
vite:
specifier: catalog:core
version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)
+ vitest:
+ specifier: catalog:test
+ version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))
plugins/inspectors:
devDependencies:
@@ -1245,6 +1248,9 @@ importers:
vite:
specifier: catalog:core
version: 8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1)
+ vitest:
+ specifier: catalog:test
+ version: 4.1.11(@types/node@26.6.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(vite@8.3.1(@types/node@26.6.2)(jiti@2.6.1)(yaml@2.9.1))
tooling/example-plugin:
devDependencies:
@@ -1473,6 +1479,15 @@ packages:
'@dprint/toml@0.7.0':
resolution: {integrity: sha512-eFaQTcfxKHB+YyTh83x7GEv+gDPuj9q5NFOTaoj5rZmQTbj6OgjjMxUicmS1R8zYcx8YAq5oA9J3YFa5U6x2gA==}
+ '@emnapi/core@1.11.2':
+ resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
+
+ '@emnapi/runtime@1.11.2':
+ resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
+
+ '@emnapi/wasi-threads@1.2.2':
+ resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
+
'@eslint-community/eslint-utils@4.10.1':
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -1600,6 +1615,13 @@ packages:
resolution: {integrity: sha512-dTa2ixPJmWVD+L13Iuxx51JIm3gaX+gnRCe8jA0PFw0xjMjLYv+0ZgbWSSVlNmowp6fqpOzfPdWADkkPNQs8LQ==}
engines: {node: '25'}
+ '@napi-rs/wasm-runtime@1.2.5':
+ resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
+ '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
+
'@octokit/auth-token@6.0.0':
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
engines: {node: '>= 20'}
@@ -1660,9 +1682,237 @@ packages:
'@octokit/types@18.0.0':
resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==}
+ '@oxc-parser/binding-android-arm-eabi@0.153.0':
+ resolution: {integrity: sha512-BcWu25dMwMjHpaDCEidmpAi7rGg1qZoihJeUUH9bD55pVZ0z4Wa5CQgqSTo0R41H81oozisQKW3WqvAN2m77IQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
+
+ '@oxc-parser/binding-android-arm64@0.153.0':
+ resolution: {integrity: sha512-9l5rdp2DhRqVriBHsNpu5cEf+hizZ4B58rt93HzyQEBUgZiC6qe2oFpaAAnXGCxHGnQExI1s7S2G0QhFacUZ6w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@oxc-parser/binding-darwin-arm64@0.153.0':
+ resolution: {integrity: sha512-EcpsyRXHhENDpekpG4QNaSZj2F/BAz4Gh4o89zZwNss86R8KyRSQ6sXbAj74Xyeb4z3j/yBZZ6joQZhraIWyfw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@oxc-parser/binding-darwin-x64@0.153.0':
+ resolution: {integrity: sha512-hlj80HUQy07/2lYp6rmxH+3u+H2ik3rjLR52CUdPApxmZCNxASDUf/+ykTzAHS/fehftmVMATKvPaWmuLHuasg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oxc-parser/binding-freebsd-x64@0.153.0':
+ resolution: {integrity: sha512-fWRcv2pMSh1qtez46MOu8vuUJ5fiaQZPHSphbWulbL7SHBMKfRgZl2PzjXZYQJbq7NEXyXbgIuIuUm+/Rmkv8w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.153.0':
+ resolution: {integrity: sha512-paZeUK1GXShl/bOJQET1/8AQxayFeuN4SIWAkgTRxE7ZQwv9uYSB2n0648smygP22N7jcI3zxAm0Z+ALNiIKxg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-arm-musleabihf@0.153.0':
+ resolution: {integrity: sha512-qFPp5NwMf4K1C0O+DCVGmcbOaYyYAq98Rvv9p4VSfb777iUwlJCqMUxf6QG9N5f5ls4TEX6LitOZHi+6GL6rYQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-parser/binding-linux-arm64-gnu@0.153.0':
+ resolution: {integrity: sha512-oufEhZCX7saeo27Logra8cvBR1jQ/yw2zUI2hHmWw3qA/XYqEW31FOvLQBv7DWN32NjVdlcaP11WBBZTJo4k9g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-parser/binding-linux-arm64-musl@0.153.0':
+ resolution: {integrity: sha512-zXYzuAqng85h6+LI3iRm0zeMLX1/K2z41ZAMWxVRgt0D10VvZUaV5q+TEB46KDhoKBpdexoTLC6QoB/EiP2oqQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxc-parser/binding-linux-ppc64-gnu@0.153.0':
+ resolution: {integrity: sha512-J7+NLwLEzdkKM9cKyPHH/AjXXstWOs98KYgLrPPnO4N9MO8EZRBfPNnBcS3S67uMOBkRpUmCl2FhRnWdQ5CbBA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-parser/binding-linux-riscv64-gnu@0.153.0':
+ resolution: {integrity: sha512-sD0K11GQTLWylln54nPMMTtgmBbQrR5It/vMpZpy/fogiw8O2AvIE+9LuWUXddrWk9zvFIHGRcSgFn4IHOfoYg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-parser/binding-linux-riscv64-musl@0.153.0':
+ resolution: {integrity: sha512-7L66uql2BhvSz51LwfExjOdn6et/ydTiQxl23VApdKzJhTc3TczA1Qt6VR/UcvlQrL2ZbytIJb447vh7p5wmUw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxc-parser/binding-linux-s390x-gnu@0.153.0':
+ resolution: {integrity: sha512-hXV3T7mNyrPR19rbPYkglkQJtoM/UNo/xwbZeeqdAxEg8PXgb4LXXuxPpGgI4bzRRCWwLUxdDSS58xIyC2u1TA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-parser/binding-linux-x64-gnu@0.153.0':
+ resolution: {integrity: sha512-Da8Vqs1xdtoDYtEjmozD7wGgJeLBQkMbR0WUBECOSxodyX5DXy8ySlvJWbukyO2Q0+TM9EK+4LT6tbq0jCg4hQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-parser/binding-linux-x64-musl@0.153.0':
+ resolution: {integrity: sha512-zGAPxNTIIw6X5WyTRscIFE/oDjpdNtrrl32ing6NOXdGjzRucadnxBilexLdeYQ9YxixF93GujpXyQ0jw7ddSg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxc-parser/binding-openharmony-arm64@0.153.0':
+ resolution: {integrity: sha512-GoHzpqP5XyPIp9DHokoEdCp+K4UPl/h1neFj1uXnUB3pJYyOQ7NOhFVOttNUgCGcj2eywAvu5cRivTOSq4S10g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@oxc-parser/binding-win32-arm64-msvc@0.153.0':
+ resolution: {integrity: sha512-xGcy12tO9k7zFbeyQzEuilfWfQpyRq23/fLPfN3wjSWuWv58k46fQT1hsO1KFNykZuVGW9xSJE/z592TmAoSDA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@oxc-parser/binding-win32-ia32-msvc@0.153.0':
+ resolution: {integrity: sha512-o1ykUjWncvXX6N5a4KebgLjtD8zRui+f1llMhSGoSC9oP2KOqapRpdwmZ3NcNG0axFouwVKmhGBjd+IhA8BksA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@oxc-parser/binding-win32-x64-msvc@0.153.0':
+ resolution: {integrity: sha512-gxQz7G96Up136fd+2zWrP+o6FYGs9NO4Dyv55IoeJFJUDwLFHcLYY11hhA5wQKs0HoM57Q8esQ3nEVhnCdsjAQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
'@oxc-project/types@0.151.0':
resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==}
+ '@oxc-project/types@0.153.0':
+ resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
+
+ '@oxc-resolver/binding-android-arm-eabi@11.24.2':
+ resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==}
+ cpu: [arm]
+ os: [android]
+
+ '@oxc-resolver/binding-android-arm64@11.24.2':
+ resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==}
+ cpu: [arm64]
+ os: [android]
+
+ '@oxc-resolver/binding-darwin-arm64@11.24.2':
+ resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@oxc-resolver/binding-darwin-x64@11.24.2':
+ resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oxc-resolver/binding-freebsd-x64@11.24.2':
+ resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
+ resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
+ resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
+ resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-resolver/binding-linux-arm64-musl@11.24.2':
+ resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
+ resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
+ resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
+ resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
+ resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-resolver/binding-linux-x64-gnu@11.24.2':
+ resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxc-resolver/binding-linux-x64-musl@11.24.2':
+ resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxc-resolver/binding-openharmony-arm64@11.24.2':
+ resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@oxc-resolver/binding-wasm32-wasi@11.24.2':
+ resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
+ resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@oxc-resolver/binding-win32-x64-msvc@11.24.2':
+ resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==}
+ cpu: [x64]
+ os: [win32]
+
'@oxfmt/binding-android-arm-eabi@0.35.0':
resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2031,6 +2281,9 @@ packages:
cpu: [arm64]
os: [win32]
+ '@tybys/wasm-util@0.10.4':
+ resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==}
+
'@types/bun@1.4.2':
resolution: {integrity: sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==}
@@ -2719,6 +2972,9 @@ packages:
fast-uri@3.1.8:
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
+ fd-package-json@2.0.0:
+ resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -2748,6 +3004,11 @@ packages:
flatted@3.4.4:
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
+ formatly@0.11.4:
+ resolution: {integrity: sha512-TeCC10lP8KCgoX+Fe5lryILIT0/Dy+nP0OSVnsuB5CwXoLuOdqxCYlPFF1inR8S/zTy1ec7TjhS2TeRKWzzImA==}
+ engines: {node: '>=18.14.0'}
+ hasBin: true
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2765,6 +3026,9 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
+ get-tsconfig@4.14.3:
+ resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
+
get-tsconfig@5.0.0-beta.5:
resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==}
engines: {node: '>=20.20.0'}
@@ -2932,6 +3196,10 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+ hasBin: true
+
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@@ -2969,6 +3237,11 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
+ knip@6.41.0:
+ resolution: {integrity: sha512-DrhQlIuV6xScd5Q+WTF1znd1w41QzxFGCtAHXCajsH/ndGMotHoNYbdXbuFtzW3mIOmniHtZ7zCtvnQkUmJtQQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
known-css-properties@0.37.0:
resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==}
@@ -3147,6 +3420,13 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
+ oxc-parser@0.153.0:
+ resolution: {integrity: sha512-cEnpyyV1OJRXv++SFNw0z8i+2o/cr88rVLaOYJafELDoHM5yHvCAbwGRLvxuany6SNLcbblSMUyZaGzH+quSYg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+
+ oxc-resolver@11.24.2:
+ resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==}
+
oxfmt@0.35.0:
resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3160,6 +3440,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ package-manager-detector@1.9.0:
+ resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -3405,6 +3688,10 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
+ strip-json-comments@5.0.3:
+ resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
+ engines: {node: '>=14.16'}
+
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
@@ -3551,6 +3838,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ unbash@5.0.0:
+ resolution: {integrity: sha512-RYNm/ISFtflWpIcXTPHHLJZ0mqzInav+L9ke9PcL+hHsSf9+Fd/syPb614Mlqs/O3AYgjnvxDOFl0a+zVqi1/w==}
+ engines: {node: '>=14'}
+
unconfig-core@7.5.0:
resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
@@ -3686,6 +3977,10 @@ packages:
jsdom:
optional: true
+ walk-up-path@4.0.0:
+ resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
+ engines: {node: 20 || >=22}
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3991,6 +4286,22 @@ snapshots:
'@dprint/toml@0.7.0': {}
+ '@emnapi/core@1.11.2':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.2
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.11.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(jiti@2.6.1))':
dependencies:
eslint: 10.11.0(jiti@2.6.1)
@@ -4143,6 +4454,13 @@ snapshots:
'@nanoforge-dev/utils-prettier-config@1.0.2': {}
+ '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
+ dependencies:
+ '@emnapi/core': 1.11.2
+ '@emnapi/runtime': 1.11.2
+ '@tybys/wasm-util': 0.10.4
+ optional: true
+
'@octokit/auth-token@6.0.0': {}
'@octokit/core@7.0.8':
@@ -4214,8 +4532,128 @@ snapshots:
dependencies:
'@octokit/openapi-types': 29.0.1
+ '@oxc-parser/binding-android-arm-eabi@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-android-arm64@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-darwin-arm64@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-darwin-x64@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-freebsd-x64@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm-musleabihf@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm64-gnu@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-arm64-musl@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-ppc64-gnu@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-riscv64-gnu@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-riscv64-musl@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-s390x-gnu@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-x64-gnu@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-linux-x64-musl@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-openharmony-arm64@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-win32-arm64-msvc@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-win32-ia32-msvc@0.153.0':
+ optional: true
+
+ '@oxc-parser/binding-win32-x64-msvc@0.153.0':
+ optional: true
+
'@oxc-project/types@0.151.0': {}
+ '@oxc-project/types@0.153.0': {}
+
+ '@oxc-resolver/binding-android-arm-eabi@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-android-arm64@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-darwin-arm64@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-darwin-x64@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-freebsd-x64@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-arm64-gnu@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-arm64-musl@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-riscv64-musl@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-s390x-gnu@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-x64-gnu@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-linux-x64-musl@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-openharmony-arm64@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-wasm32-wasi@11.24.2':
+ dependencies:
+ '@emnapi/core': 1.11.2
+ '@emnapi/runtime': 1.11.2
+ '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
+ optional: true
+
+ '@oxc-resolver/binding-win32-arm64-msvc@11.24.2':
+ optional: true
+
+ '@oxc-resolver/binding-win32-x64-msvc@11.24.2':
+ optional: true
+
'@oxfmt/binding-android-arm-eabi@0.35.0':
optional: true
@@ -4442,6 +4880,11 @@ snapshots:
'@turbo/windows-arm64@2.11.4':
optional: true
+ '@tybys/wasm-util@0.10.4':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@types/bun@1.4.2':
dependencies:
bun-types: 1.4.2
@@ -5127,6 +5570,10 @@ snapshots:
fast-uri@3.1.8: {}
+ fd-package-json@2.0.0:
+ dependencies:
+ walk-up-path: 4.0.0
+
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
@@ -5154,6 +5601,11 @@ snapshots:
flatted@3.4.4: {}
+ formatly@0.11.4:
+ dependencies:
+ fd-package-json: 2.0.0
+ package-manager-detector: 1.9.0
+
fsevents@2.3.3:
optional: true
@@ -5166,6 +5618,10 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
+ get-tsconfig@4.14.3:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
get-tsconfig@5.0.0-beta.5:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -5287,6 +5743,8 @@ snapshots:
jiti@2.6.1: {}
+ jiti@2.7.0: {}
+
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
@@ -5313,6 +5771,21 @@ snapshots:
kleur@4.1.5: {}
+ knip@6.41.0:
+ dependencies:
+ formatly: 0.11.4
+ get-tsconfig: 4.14.3
+ jiti: 2.7.0
+ oxc-parser: 0.153.0
+ oxc-resolver: 11.24.2
+ picomatch: 4.0.7
+ smol-toml: 1.9.0
+ strip-json-comments: 5.0.3
+ tinyglobby: 0.2.17
+ unbash: 5.0.0
+ yaml: 2.9.1
+ zod: 4.6.5
+
known-css-properties@0.37.0: {}
levn@0.4.1:
@@ -5459,6 +5932,52 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
+ oxc-parser@0.153.0:
+ dependencies:
+ '@oxc-project/types': 0.153.0
+ optionalDependencies:
+ '@oxc-parser/binding-android-arm-eabi': 0.153.0
+ '@oxc-parser/binding-android-arm64': 0.153.0
+ '@oxc-parser/binding-darwin-arm64': 0.153.0
+ '@oxc-parser/binding-darwin-x64': 0.153.0
+ '@oxc-parser/binding-freebsd-x64': 0.153.0
+ '@oxc-parser/binding-linux-arm-gnueabihf': 0.153.0
+ '@oxc-parser/binding-linux-arm-musleabihf': 0.153.0
+ '@oxc-parser/binding-linux-arm64-gnu': 0.153.0
+ '@oxc-parser/binding-linux-arm64-musl': 0.153.0
+ '@oxc-parser/binding-linux-ppc64-gnu': 0.153.0
+ '@oxc-parser/binding-linux-riscv64-gnu': 0.153.0
+ '@oxc-parser/binding-linux-riscv64-musl': 0.153.0
+ '@oxc-parser/binding-linux-s390x-gnu': 0.153.0
+ '@oxc-parser/binding-linux-x64-gnu': 0.153.0
+ '@oxc-parser/binding-linux-x64-musl': 0.153.0
+ '@oxc-parser/binding-openharmony-arm64': 0.153.0
+ '@oxc-parser/binding-win32-arm64-msvc': 0.153.0
+ '@oxc-parser/binding-win32-ia32-msvc': 0.153.0
+ '@oxc-parser/binding-win32-x64-msvc': 0.153.0
+
+ oxc-resolver@11.24.2:
+ optionalDependencies:
+ '@oxc-resolver/binding-android-arm-eabi': 11.24.2
+ '@oxc-resolver/binding-android-arm64': 11.24.2
+ '@oxc-resolver/binding-darwin-arm64': 11.24.2
+ '@oxc-resolver/binding-darwin-x64': 11.24.2
+ '@oxc-resolver/binding-freebsd-x64': 11.24.2
+ '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2
+ '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2
+ '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-arm64-musl': 11.24.2
+ '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2
+ '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-x64-gnu': 11.24.2
+ '@oxc-resolver/binding-linux-x64-musl': 11.24.2
+ '@oxc-resolver/binding-openharmony-arm64': 11.24.2
+ '@oxc-resolver/binding-wasm32-wasi': 11.24.2
+ '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2
+ '@oxc-resolver/binding-win32-x64-msvc': 11.24.2
+
oxfmt@0.35.0:
dependencies:
tinypool: 2.1.0
@@ -5491,6 +6010,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
+ package-manager-detector@1.9.0: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5692,6 +6213,8 @@ snapshots:
strip-final-newline@4.0.0: {}
+ strip-json-comments@5.0.3: {}
+
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
@@ -5849,6 +6372,8 @@ snapshots:
typescript@6.0.3: {}
+ unbash@5.0.0: {}
+
unconfig-core@7.5.0:
dependencies:
'@quansync/fs': 1.1.0
@@ -5928,6 +6453,8 @@ snapshots:
transitivePeerDependencies:
- msw
+ walk-up-path@4.0.0: {}
+
which@2.0.2:
dependencies:
isexe: 2.0.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index b9a857c2..0d8757f0 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -26,7 +26,6 @@ catalogs:
'@fontsource/instrument-sans': ^5.3.0
'@fontsource/jetbrains-mono': ^5.3.0
bits-ui: ^2.19.3
- paneforge: ^1.0.2
monaco-editor: ^0.55.1
config:
'@nanoforge-dev/config': ^2.3.0
@@ -54,6 +53,7 @@ catalogs:
eslint: ^10.6.0
eslint-plugin-svelte: ^3.20.0
globals: ^17.7.0
+ knip: ^6.38.0
prettier: ^3.9.4
prettier-plugin-svelte: ^4.1.1
typescript-eslint: ^8.62.1
diff --git a/scripts/check-workspaces.mjs b/scripts/check-workspaces.mjs
new file mode 100644
index 00000000..a6794672
--- /dev/null
+++ b/scripts/check-workspaces.mjs
@@ -0,0 +1,146 @@
+#!/usr/bin/env node
+/**
+ * Checks every workspace against the repository's standards (after the engine's): the order of
+ * its package.json fields, its files, its label in the three label files and its choice in the
+ * issue forms. Workspaces come from pnpm-workspace.yaml, never from a folder scan. Run by
+ * `pnpm lint`; exits 1 and names each problem.
+ */
+import { existsSync, readFileSync, readdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const MAIN = process.argv[1] === fileURLToPath(import.meta.url);
+/** The repository, or the folder given to the script (to check a copy). */
+const ROOT = (MAIN && process.argv[2]) || join(dirname(fileURLToPath(import.meta.url)), '..');
+
+/** The engine's order of package.json fields. */
+const FIELDS = [
+ '$schema',
+ 'name',
+ 'version',
+ 'description',
+ 'keywords',
+ 'homepage',
+ 'bugs',
+ 'license',
+ 'contributors',
+ 'files',
+ 'main',
+ 'module',
+ 'types',
+ 'exports',
+ 'type',
+ 'directories',
+ 'repository',
+ 'funding',
+ 'scripts',
+ 'dependencies',
+ 'peerDependencies',
+ 'devDependencies',
+ 'packageManager',
+ 'engines',
+ 'private',
+ 'publishConfig',
+ 'lint-staged',
+];
+const REQUIRED = ['README.md', 'LICENSE', '.prettierignore'];
+/** Published workspaces also keep a changelog and its release config. */
+const PUBLISHED = new Set(['apps/editor', 'packages/sdk', 'tooling/vite-plugin']);
+const RELEASE = ['CHANGELOG.md', 'cliff.toml', '.cliff-jumperrc.json'];
+const DEPENDENCIES = ['dependencies', 'peerDependencies', 'devDependencies'];
+/** Issue forms and the workspace groups they offer. */
+const FORMS = {
+ '01-packages_bug_report.yml': ['apps', 'packages', 'plugins'],
+ '03-feature_request.yml': ['apps', 'packages', 'plugins'],
+ '02-tooling_bug_report.yml': ['tooling'],
+};
+
+const read = (path) => readFileSync(join(ROOT, path), 'utf8');
+
+/** `apps/*`-style globs of pnpm-workspace.yaml. */
+export const workspaceGlobs = (yaml) => {
+ const block = /^packages:\n((?:\s+-\s+.+\n?)+)/m.exec(yaml)?.[1] ?? '';
+ return [...block.matchAll(/-\s+['"]?([^'"\n]+)['"]?/g)].map((match) => match[1].trim());
+};
+
+export const workspaces = () =>
+ workspaceGlobs(read('pnpm-workspace.yaml')).flatMap((glob) => {
+ if (!glob.endsWith('/*')) return existsSync(join(ROOT, glob, 'package.json')) ? [glob] : [];
+ const group = glob.slice(0, -2);
+ return readdirSync(join(ROOT, group), { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => `${group}/${entry.name}`)
+ .filter((path) => existsSync(join(ROOT, path, 'package.json')));
+ });
+
+/** Problems of one package.json: fields out of the engine's order, unsorted dependencies. */
+export const manifestProblems = (manifest) => {
+ const problems = [];
+ const keys = Object.keys(manifest);
+ const unknown = keys.filter((key) => !FIELDS.includes(key));
+ if (unknown.length)
+ problems.push(`package.json has fields the engine has not: ${unknown.join(', ')}`);
+ const known = keys.filter((key) => FIELDS.includes(key));
+ const expected = [...known].sort((a, b) => FIELDS.indexOf(a) - FIELDS.indexOf(b));
+ const at = known.findIndex((key, index) => key !== expected[index]);
+ if (at !== -1)
+ problems.push(
+ `package.json field "${known[at]}" is out of order (expected "${expected[at]}" there)`,
+ );
+ for (const field of DEPENDENCIES) {
+ const names = Object.keys(manifest[field] ?? {});
+ const sorted = [...names].sort();
+ if (names.join() !== sorted.join()) problems.push(`package.json ${field} are not sorted`);
+ }
+ return problems;
+};
+
+const labelsOf = {
+ 'labels.yml': (text) => new Set([...text.matchAll(/^- name: '([^']+)'/gm)].map((m) => m[1])),
+ 'labeler.yml': (text) => new Set([...text.matchAll(/^([\w-]+:[\w-]+):$/gm)].map((m) => m[1])),
+ 'issue-labeler.yml': (text) =>
+ new Set([...text.matchAll(/^([\w-]+:[\w-]+):$/gm)].map((m) => m[1])),
+};
+
+/** The choices of the first dropdown of an issue form. */
+const formChoices = (text) => {
+ const block = /options:\n((?:\s+- .+\n)+)/.exec(text)?.[1] ?? '';
+ return new Set([...block.matchAll(/- (.+)/g)].map((m) => m[1].trim()));
+};
+
+export const check = () => {
+ const problems = [];
+ const labels = Object.fromEntries(
+ Object.entries(labelsOf).map(([file, parse]) => [file, parse(read(`.github/${file}`))]),
+ );
+ const forms = Object.fromEntries(
+ Object.keys(FORMS).map((file) => [file, formChoices(read(`.github/ISSUE_TEMPLATE/${file}`))]),
+ );
+ for (const path of workspaces()) {
+ const report = (message) => problems.push(`${path}: ${message}`);
+ const [group, name] = path.split('/');
+ for (const message of manifestProblems(JSON.parse(read(`${path}/package.json`))))
+ report(message);
+ const files = PUBLISHED.has(path) ? [...REQUIRED, ...RELEASE] : REQUIRED;
+ for (const file of files) if (!existsSync(join(ROOT, path, file))) report(`has no ${file}`);
+ const label = `${group}:${name}`;
+ for (const [file, names] of Object.entries(labels))
+ if (!names.has(label)) report(`has no "${label}" label in .github/${file}`);
+ for (const [file, groups] of Object.entries(FORMS))
+ if (groups.includes(group) && !forms[file].has(name))
+ report(`is not a choice of .github/ISSUE_TEMPLATE/${file}`);
+ }
+ return problems;
+};
+
+if (MAIN) {
+ const problems = check();
+ for (const problem of problems) console.error(`✖ ${problem}`);
+ if (problems.length) {
+ console.error(
+ `\n${problems.length} problem(s) in the workspaces (scripts/check-workspaces.mjs)`,
+ );
+ process.exit(1);
+ }
+ console.log(`✓ ${workspaces().length} workspaces follow the repository's standards`);
+}
diff --git a/scripts/coverage-floors.mjs b/scripts/coverage-floors.mjs
new file mode 100644
index 00000000..129e568b
--- /dev/null
+++ b/scripts/coverage-floors.mjs
@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+/**
+ * Prints the coverage of each workspace from its last `pnpm test:coverage`
+ * (`coverage/coverage-summary.json`). With `--write`, raises `coverage-floors.json` to one point
+ * under today's values, never lowering a floor: coverage only goes up.
+ */
+import { existsSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { workspaces } from './check-workspaces.mjs';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const FLOORS_FILE = join(ROOT, 'coverage-floors.json');
+const METRICS = ['lines', 'statements', 'functions', 'branches'];
+
+const floors = JSON.parse(readFileSync(FLOORS_FILE, 'utf8'));
+const rows = [];
+for (const path of workspaces()) {
+ const summary = join(ROOT, path, 'coverage/coverage-summary.json');
+ if (!existsSync(summary)) continue;
+ const { name } = JSON.parse(readFileSync(join(ROOT, path, 'package.json'), 'utf8'));
+ const { total } = JSON.parse(readFileSync(summary, 'utf8'));
+ const today = Object.fromEntries(METRICS.map((metric) => [metric, total[metric].pct]));
+ rows.push({ path, ...today });
+ const floor = floors[name] ?? {};
+ for (const metric of METRICS)
+ floor[metric] = Math.max(floor[metric] ?? 0, Math.max(0, Math.floor(today[metric]) - 1));
+ floors[name] = floor;
+}
+
+console.table(rows);
+if (process.argv.includes('--write')) {
+ const sorted = Object.fromEntries(Object.entries(floors).sort(([a], [b]) => a.localeCompare(b)));
+ writeFileSync(FLOORS_FILE, `${JSON.stringify(sorted, null, 2)}\n`);
+ console.log(`Floors written for ${rows.length} workspaces (coverage-floors.json)`);
+}
diff --git a/scripts/engine-bridge.mjs b/scripts/engine-bridge.mjs
index d7af004a..8ec31878 100644
--- a/scripts/engine-bridge.mjs
+++ b/scripts/engine-bridge.mjs
@@ -12,11 +12,22 @@ import {
import { dirname, join, resolve } from 'node:path';
/**
- * The engine checkout the scripts link games to: `NANOFORGE_ENGINE`, or `engine` next to the
- * editor repository (`root`).
+ * The engine checkout the scripts link games to: `NANOFORGE_ENGINE`, else the one
+ * `pnpm setup:engine` built in `.engine/engine`, else `engine` next to the editor repository
+ * (`root` is the editor repository).
*/
-export const engineRoot = (root) =>
- resolve(process.env.NANOFORGE_ENGINE ?? join(root, '../engine'));
+export const engineRoot = (root) => {
+ if (process.env.NANOFORGE_ENGINE) return resolve(process.env.NANOFORGE_ENGINE);
+ const pinned = join(root, '.engine/engine');
+ return existsSync(join(pinned, 'package.json')) ? pinned : resolve(join(root, '../engine'));
+};
+
+/** The CLI build the tests link: `NANOFORGE_CLI`, else the one `pnpm setup:engine` built. */
+export const cliRoot = (root) => {
+ if (process.env.NANOFORGE_CLI) return resolve(process.env.NANOFORGE_CLI);
+ const pinned = join(root, '.engine/cli');
+ return existsSync(join(pinned, 'package.json')) ? pinned : undefined;
+};
const speaks = (file) =>
existsSync(file) && readFileSync(file, 'utf8').includes('EDITOR_PROTOCOL_VERSION');
diff --git a/scripts/pinned-refs.json b/scripts/pinned-refs.json
new file mode 100644
index 00000000..f67716dd
--- /dev/null
+++ b/scripts/pinned-refs.json
@@ -0,0 +1,10 @@
+{
+ "engine": {
+ "repository": "https://github.com/NanoForge-dev/Engine.git",
+ "ref": "3423bac441053ce1cd43e1a2a0ecc902447d083c"
+ },
+ "cli": {
+ "repository": "https://github.com/NanoForge-dev/CLI.git",
+ "ref": "044015451885e5ef2a3ad6475477ae326fc21406"
+ }
+}
diff --git a/scripts/setup-check.mjs b/scripts/setup-check.mjs
new file mode 100644
index 00000000..5baeb40d
--- /dev/null
+++ b/scripts/setup-check.mjs
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+/**
+ * `pnpm setup:check`: checks what working on the editor needs, and says how to get what is missing: Node, pnpm,
+ * Bun and git to build and run it; Chrome for the browser tests; a built engine and CLI for the
+ * e2e tests that play games (`pnpm setup:engine` makes both). Exits 1 when a requirement fails.
+ */
+import { execFileSync } from 'node:child_process';
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { bridgeOf, cliRoot, engineRoot } from './engine-bridge.mjs';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const manifest = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
+
+/** The first line a command prints, or undefined when it does not run. */
+const output = (command, ...args) => {
+ try {
+ return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
+ .trim()
+ .split('\n')[0];
+ } catch {
+ return undefined;
+ }
+};
+const major = (version) => Number(/(\d+)/.exec(version ?? '')?.[1]);
+
+let failed = 0;
+/** One line per check: ✓, ✗ (required) or ! (optional), and how to fix it. */
+const report = (ok, required, label, fix) => {
+ if (ok) return console.log(`✓ ${label}`);
+ if (required) failed++;
+ console.log(`${required ? '✗' : '!'} ${label}\n ${fix}`);
+};
+
+const node = process.versions.node;
+const wanted = manifest.engines.node;
+report(
+ major(node) === major(wanted),
+ true,
+ `Node ${node} (wants ${wanted})`,
+ `Install Node ${wanted} (nvm install ${wanted}).`,
+);
+
+const pnpmWanted = manifest.packageManager.split('@')[1];
+const pnpm = output('pnpm', '--version');
+report(
+ major(pnpm) === major(pnpmWanted),
+ true,
+ `pnpm ${pnpm ?? 'missing'} (wants ${pnpmWanted})`,
+ `corepack enable && corepack prepare pnpm@${pnpmWanted} --activate`,
+);
+
+const bun = output('bun', '--version');
+report(
+ !!bun,
+ true,
+ `Bun ${bun ?? 'missing'} (the editor server runs on it)`,
+ 'Install Bun: https://bun.sh',
+);
+
+const git = output('git', '--version');
+report(!!git, true, git ?? 'git missing', 'Install git.');
+
+const chromes = [
+ process.env.CHROME,
+ '/usr/bin/google-chrome-stable',
+ '/usr/bin/google-chrome',
+ '/usr/bin/chromium',
+];
+const chrome = chromes.find((path) => path && existsSync(path));
+report(
+ !!chrome,
+ false,
+ chrome
+ ? `Chrome at ${chrome} (set CHROME=${chrome} for the tests)`
+ : 'No Chrome for the browser tests',
+ 'Install Chrome, or let Playwright fetch its own: pnpm --filter @nanoforge-dev/editor exec playwright install chromium',
+);
+
+const engine = engineRoot(ROOT);
+const pong = join(engine, 'example/pong-network/node_modules/.bin/nf');
+report(
+ existsSync(pong) && !!bridgeOf(engine),
+ false,
+ `Built engine with the editor bridge at ${engine}`,
+ 'The e2e tests that play games are skipped without it: pnpm setup:engine (needs emcc), or NANOFORGE_ENGINE=.',
+);
+report(
+ existsSync(join(engine, 'modules/scene/dist/index.js')),
+ false,
+ "The engine's scene module is built",
+ 'The scene e2e tests are skipped without it: build the engine (pnpm setup:engine).',
+);
+
+const cli = cliRoot(ROOT);
+report(
+ !!cli && existsSync(join(cli, 'dist/nf.js')),
+ false,
+ cli ? `Built CLI at ${cli}` : 'No built CLI (NANOFORGE_CLI)',
+ 'The tutorial e2e test is skipped without it: pnpm setup:engine, or NANOFORGE_CLI= .',
+);
+
+report(
+ !!output('emcc', '--version'),
+ false,
+ 'emscripten (emcc), to build the engine',
+ 'Only pnpm setup:engine needs it: https://emscripten.org/docs/getting_started/downloads.html',
+);
+
+console.log(failed ? `\n${failed} requirement(s) missing.` : '\nReady.');
+process.exit(failed ? 1 : 0);
diff --git a/scripts/setup-engine.mjs b/scripts/setup-engine.mjs
new file mode 100644
index 00000000..9374bb54
--- /dev/null
+++ b/scripts/setup-engine.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/**
+ * Clones and builds the engine and the CLI the e2e tests and `pnpm example` use, at the commits
+ * CI pins (`scripts/pinned-refs.json`), into `.engine/` (ignored by git). It never touches a
+ * checkout of yours: set NANOFORGE_ENGINE and NANOFORGE_CLI to use one instead.
+ *
+ * `--dry-run` prints the commands. Building the engine needs emscripten (`emcc`, see the
+ * engine's README).
+ */
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const TARGET = join(ROOT, '.engine');
+const REFS = JSON.parse(readFileSync(join(ROOT, 'scripts/pinned-refs.json'), 'utf8'));
+const DRY = process.argv.includes('--dry-run');
+
+const run = (command, args, cwd) => {
+ console.log(`$ ${[command, ...args].join(' ')}${cwd === ROOT ? '' : ` (in ${cwd})`}`);
+ if (!DRY)
+ execFileSync(command, args, { cwd, stdio: 'inherit', env: { ...process.env, HUSKY: '0' } });
+};
+
+/** A clone of `repository` at `ref` in `.engine/`, fetched again when the ref moved. */
+const checkout = (name, { repository, ref }) => {
+ const folder = join(TARGET, name);
+ if (!existsSync(join(folder, '.git')))
+ run('git', ['clone', '--filter=blob:none', repository, folder], ROOT);
+ else run('git', ['fetch', '--quiet', 'origin'], folder);
+ run('git', ['checkout', '--quiet', '--detach', ref], folder);
+ return folder;
+};
+
+if (!DRY) mkdirSync(TARGET, { recursive: true });
+
+const engine = checkout('engine', REFS.engine);
+run('pnpm', ['install', '--frozen-lockfile'], engine);
+run('pnpm', ['run', 'build'], engine);
+
+const cli = checkout('cli', REFS.cli);
+run('pnpm', ['install', '--frozen-lockfile'], cli);
+run('pnpm', ['--filter', '@nanoforge-dev/config', 'build'], cli);
+/** `nf new` reads the schematics' dist, which the CLI's own build leaves out. */
+run('pnpm', ['--filter', '@nanoforge-dev/schematics', 'build'], cli);
+run('pnpm', ['build'], cli);
+
+console.log(`
+The e2e tests and \`pnpm example\` now find them by themselves; or set
+ NANOFORGE_ENGINE=${engine}
+ NANOFORGE_CLI=${cli}`);
diff --git a/tooling/vite-plugin/package.json b/tooling/vite-plugin/package.json
index 334bd0b9..57e51558 100644
--- a/tooling/vite-plugin/package.json
+++ b/tooling/vite-plugin/package.json
@@ -44,6 +44,7 @@
"format": "prettier --write . && eslint --fix --format=pretty src",
"typecheck": "tsc -p tsconfig.json",
"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 'tooling/vite-plugin/*'",
"release": "cliff-jumper"
},
diff --git a/turbo.json b/turbo.json
index 8e25027e..bf34c6ac 100644
--- a/turbo.json
+++ b/turbo.json
@@ -4,6 +4,8 @@
"tsconfig.json",
"eslint.config.js",
"vitest.config.ts",
+ "vitest.coverage.ts",
+ "coverage-floors.json",
"vitest.setup.ts",
"vitest.global-setup.ts",
"prettier.config.js"
@@ -48,6 +50,12 @@
"inputs": ["src/**", "test/**", "package.json", "tsconfig.json", "*.config.{js,ts}"],
"outputs": [],
"outputLogs": "new-only"
+ },
+ "test:coverage": {
+ "dependsOn": ["^build"],
+ "inputs": ["src/**", "test/**", "package.json", "tsconfig.json", "*.config.{js,ts}"],
+ "outputs": ["coverage/**"],
+ "outputLogs": "new-only"
}
},
"globalPassThroughEnv": ["CHROME", "CI"]
diff --git a/vitest.config.ts b/vitest.config.ts
index d29009a1..32824546 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,6 +1,8 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
+import { coverage } from './vitest.coverage.ts';
+
/**
* Shared unit-test config. Each package runs `vitest run --config ../../vitest.config.ts`
* from its own directory, so `include` is relative to the package.
@@ -14,11 +16,6 @@ export default defineConfig({
testTimeout: 30_000,
globalSetup: [fileURLToPath(new URL('./vitest.global-setup.ts', import.meta.url))],
setupFiles: [fileURLToPath(new URL('./vitest.setup.ts', import.meta.url))],
- coverage: {
- provider: 'v8',
- reporter: ['text', 'lcov'],
- include: ['src'],
- exclude: ['**/*.{type,d}.ts', '**/index.ts'],
- },
+ coverage: coverage(),
},
});
diff --git a/vitest.coverage.ts b/vitest.coverage.ts
new file mode 100644
index 00000000..4e8a71b4
--- /dev/null
+++ b/vitest.coverage.ts
@@ -0,0 +1,25 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+type Floors = Partial>;
+
+const FLOORS: Record = JSON.parse(
+ readFileSync(new URL('./coverage-floors.json', import.meta.url), 'utf8'),
+);
+
+/**
+ * Coverage of the package the tests run in (vitest runs from its folder): v8, lcov for CI and
+ * a summary for `scripts/coverage-floors.mjs`, failing under the package's floors in
+ * `coverage-floors.json` (today's values, so coverage only goes up). Svelte files count only
+ * where browser tests mount them (`svelte: true`, the kit): Node tests cannot instrument them.
+ */
+export const coverage = ({ svelte = false }: { svelte?: boolean } = {}) => {
+ const { name } = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'));
+ return {
+ provider: 'v8' as const,
+ reporter: ['text-summary', 'lcov', 'json-summary'],
+ include: svelte ? ['src'] : ['src/**/*.{ts,js}'],
+ exclude: ['**/*.{type,d}.ts', '**/index.ts'],
+ thresholds: FLOORS[name] ?? {},
+ };
+};