Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na
- Plain values that change outside Svelte are made reactive with a `revision` counter bumped by subscriptions, read with `void revision` in `$derived.by`.
- Lint wants `SvelteSet`/`SvelteMap` for mutable collections. For a throwaway copy, disable `svelte/prefer-svelte-reactivity` on that line.
- **Docks (IntelliJ tool windows):** six slots (`bottom` is the bottom row's left part, `bottomRight` its right part). Their panels are icons in the left and right stripes (`ToolStripe.svelte`), and a docked stack shows only the active panel's header. Clicking the icon of a shown panel **hides its slot**: in e2e, use `showPanel`/`panelTab` (`e2e/helpers.ts`), never a bare click on a panel's tab. Floats keep a tab strip. A dock's icon shows before its plugin activates: built-in widget icons live in the kit (`icons.ts`).
- **Kit z-order:** dock 10 < float 100 < dialog 2000 < menu 2500 < toast 3000. Menus, selects and tooltips must open above dialogs.
- **Kit z-order:** dock 10 < float 100 < tour 1500 < dialog 2000 < menu 2500 < toast 3000. Menus, selects and tooltips must open above dialogs.
- **Menus:** checked menu entries are plain `menuitem`s with a check icon (no `menuitemcheckbox` role).
- **Actions and shortcuts:**
- An _action_ is a command with its arguments (`actionId`). The palette and the keymap page list `listActions`: titled commands, plus menu entries that give arguments.
Expand Down
3 changes: 3 additions & 0 deletions apps/editor/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ test-results/
e2e/.workspace*/
.nanoforge/
.data/

# The tutorial project's files, written into a game as nf new writes its own.
tutorial/
39 changes: 39 additions & 0 deletions apps/editor/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,42 @@ export const openProject = async (page: Page, path: string): Promise<void> => {
await page.goto(`/load?path=${encodeURIComponent(path)}`);
await page.getByRole('button', { name: 'Trust and open', exact: true }).click();
};

/** The settings RPC of the editor server, as the page's own session (account settings). */
const settingsRpc = async (page: Page, method: string, input: unknown) => {
const response = await page.request.post(`/rpc/settings.${method}`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify(input),
});
const body = (await response.json()) as {
result?: { revision: string | null; values: Record<string, unknown> };
};
return { ok: response.ok(), result: body.result };
};

/** An account setting as the editor server keeps it (`undefined`: not set). */
export const accountSetting = async (page: Page, key: string): Promise<unknown> =>
(await settingsRpc(page, 'accountGet', null)).result?.values[key];

/**
* Puts an account setting back (`undefined` removes it). Account settings outlive tests (and the
* CI retry): a test that changes one reads it first, then puts it back. Written on the server
* directly, so nothing waits for the page's debounced push.
*/
export const putBackAccountSetting = async (
page: Page,
key: string,
value: unknown,
): Promise<void> => {
for (let attempt = 0; attempt < 5; attempt++) {
const document = (await settingsRpc(page, 'accountGet', null)).result;
if (!document) break;
const values = Object.fromEntries(
Object.entries(document.values).filter(([name]) => name !== key),
);
if (value !== undefined) values[key] = value;
if ((await settingsRpc(page, 'accountPut', { baseRevision: document.revision, values })).ok)
return;
}
throw new Error(`Could not put back the account setting ${key}`);
};
49 changes: 49 additions & 0 deletions apps/editor/e2e/prepare-workspace.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
cpSync,
existsSync,
Expand Down Expand Up @@ -147,6 +149,23 @@ const copyGame = (source, destination) => {
}
};

/**
* The panels' first-time tips take room the tests did not plan for: the local account starts
* with them put away, but for History's (`tips.spec.ts`).
*/
const tips = readdirSync(join(import.meta.dirname, '../../../plugins'))
.map((plugin) => join(import.meta.dirname, '../../../plugins', plugin, 'nanoforge.manifest.json'))
.filter((file) => existsSync(file))
.flatMap((file) => JSON.parse(readFileSync(file, 'utf8')).contributes?.widgets ?? [])
.filter((widget) => widget.tip && widget.id !== 'history-panel.history' && !process.env.E2E_TIPS)
.map((widget) => widget.id);
const account = createHash('sha256').update('local').digest('hex').slice(0, 16);
mkdirSync(join(root, '.data/settings/accounts'), { recursive: true });
writeFileSync(
join(root, `.data/settings/accounts/${account}.json`),
`${JSON.stringify({ revision: '1', values: { 'tips.dismissed': tips } }, null, 2)}\n`,
);

const engine = engineRoot(join(import.meta.dirname, '../../..'));
const pong = join(engine, 'example/pong-network');
/** Engines without the editor bridge cannot be paused, stepped or stopped. */
Expand Down Expand Up @@ -194,6 +213,36 @@ if (existsSync(join(pong, 'node_modules/.bin/nf')) && bridge) {
'',
].join('\n'),
);
if (cli && existsSync(join(cli, 'dist/nf.js'))) {
/** The tutorial's project, as `projects.createTutorial` makes it, on the engine's libraries. */
const tutorial = join(root, 'my-first-game');
execFileSync(
'node',
[
join(cli, 'dist/nf.js'),
'new',
...['--name', 'my-first-game', '--path', 'my-first-game', '--language', 'ts'],
...['--package-manager', 'npm', '--no-strict', '--no-server', '--no-git', '--no-docker'],
'--skip-install',
],
{ cwd: root, stdio: 'ignore' },
);
cpSync(join(import.meta.dirname, '../tutorial'), tutorial, { recursive: true, force: true });
writeFileSync(
join(tutorial, '.gitignore'),
`${readFileSync(join(tutorial, '.gitignore'), 'utf8')}\ndist/\n`,
);
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'));
symlinkSync(
realpathSync(join(pong, 'node_modules/@nanoforge-dev/config')),
join(tutorial, 'node_modules/@nanoforge-dev/config'),
);
linkEditorLibrary(engine, tutorial);
} else {
console.warn('No built CLI in NANOFORGE_CLI: the tutorial e2e test is skipped.');
}
if (existsSync(join(engine, 'modules/scene/dist/index.js'))) {
const breakout = join(root, 'breakout');
cpSync(join(import.meta.dirname, '../../../examples/breakout'), breakout, {
Expand Down
6 changes: 4 additions & 2 deletions apps/editor/e2e/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ export const test = base.extend<{ pageErrors: string[] }>({
console.log(`[page ${message.type()}] ${message.text()}`),
);
context.on('weberror', (webError) => {
const { message } = webError.error();
if (!IGNORED.has(message)) errors.push(message);
const { message, stack } = webError.error();
if (IGNORED.has(message)) return;
/** `E2E_STACKS=1`: where it was thrown, for errors that only show in a full run. */
errors.push(process.env.E2E_STACKS && stack ? `${message}\n${stack}` : message);
});
await use(errors);
base.expect(errors, 'uncaught errors in the page').toEqual([]);
Expand Down
40 changes: 40 additions & 0 deletions apps/editor/e2e/tips.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
accountSetting,
openProject,
putBackAccountSetting,
resetLayout,
showPanel,
} from './helpers';
import { expect, test } from './test';

/** The tips put away are an account setting, which outlives tests (and the CI retry). */
let dismissed: unknown;
test.beforeEach(async ({ page }) => {
dismissed = await accountSetting(page, 'tips.dismissed');
});
test.afterEach(async ({ page }) => {
await putBackAccountSetting(page, 'tips.dismissed', dismissed);
});

test('a panel tip shows until put away, and stays away', async ({ page }) => {
await openProject(page, 'pong');
await page.waitForURL(/\/project\//);
await resetLayout(page);
await showPanel(page, 'History');
const panel = page.getByRole('region', { name: 'History', exact: true });
const tip = panel.getByRole('complementary', { name: 'Tip' });
await expect(tip).toContainText('The steps of what you are working on.');
await expect(tip.getByRole('link', { name: 'Learn more' })).toHaveAttribute(
'href',
'https://docs.nanoforge.eu/docs/editor/overview',
);
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 page.reload();
await showPanel(page, 'History');
await expect(page.getByRole('region', { name: 'History', exact: true })).toBeVisible();
await expect(tip).toHaveCount(0);
await resetLayout(page);
});
137 changes: 137 additions & 0 deletions apps/editor/e2e/tutorial.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

import { accountSetting, openProject, putBackAccountSetting } from './helpers';
import { type Page, expect, test } from './test';
import { TUTORIAL } from './workspace';

const MAIN = join(TUTORIAL, 'src/main.ts');
const SYSTEM = join(TUTORIAL, 'src/systems/move-2d.system.ts');
const sources = existsSync(MAIN)
? { [MAIN]: readFileSync(MAIN, 'utf8'), [SYSTEM]: readFileSync(SYSTEM, 'utf8') }
: {};

const bubble = (page: Page) =>
page.getByRole('dialog').filter({ has: page.locator('#nf-tour-title') });
const step = (page: Page, title: string) =>
expect(page.locator('#nf-tour-title')).toHaveText(title, { timeout: 30_000 });
const press = (page: Page, name: string) =>
bubble(page).getByRole('button', { name, exact: true }).click();
const run = (page: Page, name: string) =>
page.getByRole('toolbar', { name: 'Run' }).getByRole('button', { name, exact: false });

test.skip(
!existsSync(join(TUTORIAL, 'package.json')),
'needs a built CLI (NANOFORGE_CLI) and engine (NANOFORGE_ENGINE)',
);

/** Starting the tutorial puts the welcome away, in the account settings that outlive tests. */
let welcome: unknown;
test.beforeEach(async ({ page }) => {
welcome = await accountSetting(page, 'welcome.show');
});

test.afterEach(async ({ page }) => {
await putBackAccountSetting(page, 'welcome.show', welcome);
const stop = run(page, 'Stop');
if (await stop.isVisible().catch(() => false)) {
await stop.click();
await expect(run(page, 'Play')).toBeVisible({ timeout: 15_000 });
}
for (const [path, text] of Object.entries(sources)) writeFileSync(path, text);
for (const folder of ['.git', 'dist'])
rmSync(join(TUTORIAL, folder), { recursive: true, force: true });
});

test('the tutorial: from the welcome to a first commit', async ({ page }) => {
test.setTimeout(240_000);
/** A folder the tutorial did not make here is not trusted: open it once, as its user would. */
await openProject(page, 'my-first-game');
await page.waitForURL(/\/project\//);
await page.goto('/');
await page
.getByRole('region', { name: 'Welcome to NanoForge' })
.getByRole('button', { name: 'Start the tutorial' })
.click();
await page.waitForURL(/\/project\/[^?]+$/);

await step(page, 'Your first game');
await press(page, 'Next');
await step(page, 'Screens');
await press(page, 'Next');
await step(page, 'The Scene screen');
await page.getByRole('tab', { name: 'Scene', exact: true }).click();

await step(page, 'Add an entity');
await press(page, 'Show Hierarchy');
await page.getByRole('button', { name: 'Add entity' }).click();
await step(page, 'Select the ball');
await expect
.poll(() => readFileSync(MAIN, 'utf8'))
.toContain('const entity = registry.spawnEntity();');
await page
.getByRole('tree', { name: 'Entities' })
.getByRole('treeitem', { name: /^ball\b/ })
.click();
await press(page, 'Next');

await step(page, 'Change a value');
const speed = page
.getByRole('region', { name: 'Velocity2D' })
.getByRole('textbox', { name: 'x', exact: true });
await speed.fill('6');
await speed.press('Enter');

await step(page, 'Fix the mistake');
await press(page, 'Show problems');
await expect(page.getByText("Property 'speed' does not exist")).toBeVisible();
await expect(
page.getByRole('button').and(page.getByTitle('Show problems', { exact: true })),
'Problems should hold only the planted mistake: another error comes from the environment (a failed build, missing dependencies), and the step would never go on',
).toHaveText(/^\s*1 error(?!s)/);
writeFileSync(SYSTEM, readFileSync(SYSTEM, 'utf8').replace('velocity.speed', 'velocity.x'));

await step(page, 'Play');
await run(page, 'Play').click();
await step(page, 'Look inside the running game');
await press(page, 'Show World');
await expect(page.getByRole('region', { name: 'World', exact: true })).toBeVisible();
await run(page, 'Stop').click();
await expect(run(page, 'Play')).toBeVisible({ timeout: 15_000 });
await press(page, 'Next');

await step(page, 'Keep your work in git');
await press(page, 'Initialize repository');
await step(page, 'Commit');
/** The editor made this repository: a runner without a git identity could not commit in it. */
execFileSync('git', ['config', 'user.name', 'E2E'], { cwd: TUTORIAL });
execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: TUTORIAL });
await press(page, 'Show Commit');
const commit = page.getByRole('region', { name: 'Commit', exact: true });
await commit
.getByRole('region', { name: 'Unversioned files', exact: true })
.getByRole('checkbox', { name: 'Include all: Unversioned files', includeHidden: true })
.check();
await commit.getByRole('textbox', { name: 'Commit message' }).fill('my first game');
await commit.getByRole('button', { name: 'Commit', exact: true }).click();

await step(page, 'Done');
await press(page, 'Done');
await expect(bubble(page)).toHaveCount(0);
expect(await page.evaluate(() => document.activeElement?.isConnected ?? true)).toBe(true);
expect(existsSync(join(TUTORIAL, '.git'))).toBe(true);
});

test('Help › Tutorial replays the tour, and Escape ends it', async ({ page }) => {
await openProject(page, 'my-first-game');
await page.waitForURL(/\/project\//);
await page.getByRole('button', { name: 'Help', exact: true }).click();
await page.getByRole('menuitem', { name: 'Tutorial' }).click();
await step(page, 'Your first game');
await expect(bubble(page).getByText(/^Tutorial · 1 of \d+$/)).toBeVisible();
await bubble(page).getByRole('button', { name: 'Next', exact: true }).focus();
await page.keyboard.press('Escape');
await expect(bubble(page)).toHaveCount(0);
expect(await page.evaluate(() => document.activeElement?.isConnected ?? true)).toBe(true);
});
2 changes: 2 additions & 0 deletions apps/editor/e2e/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ export const PONG = join(WORKSPACE, 'pong');
export const GAME = join(WORKSPACE, 'game');
/** The breakout example (scenes), when the engine has the scene module. */
export const BREAKOUT = join(WORKSPACE, 'breakout');
/** The tutorial's project (`my-first-game`), when `NANOFORGE_CLI` has a built CLI. */
export const TUTORIAL = join(WORKSPACE, 'my-first-game');
/** The folder the editor server uses as the registry of plugins and packages. */
export const REGISTRY = join(WORKSPACE, '.registry');
1 change: 1 addition & 0 deletions apps/editor/scripts/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ if (existsSync(pluginsRoot)) {
cpSync(built, join(dist, 'plugins', name), { recursive: true });
}
}
cpSync(join(root, 'tutorial'), join(dist, 'tutorial'), { recursive: true });
console.log(`Editor server built in ${dist}`);
7 changes: 7 additions & 0 deletions apps/editor/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Entry of the editor server (`nf editor` runs `bun dist/index.js`): serves the RPC endpoint,
* the WebSocket, plugin files and the built UI.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';

import { type SocketData, createEditorServer, loadEnv } from '@nanoforge-dev/editor-server-core';
Expand All @@ -14,6 +15,12 @@ const editor = createEditorServer({
env: {
...env,
bundledPluginsDir: env.bundledPluginsDir ?? join(import.meta.dir, 'plugins'),
/** Next to the built server, or the sources' `tutorial/` when run from them (dev). */
tutorialDir:
env.tutorialDir ??
[join(import.meta.dir, 'tutorial'), join(import.meta.dir, '../tutorial')].find((dir) =>
existsSync(dir),
),
},
version: pkg.version,
...(process.env.EDITOR_NO_STATIC ? {} : { staticDir }),
Expand Down
4 changes: 4 additions & 0 deletions apps/editor/src/lib/editor/create-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
type Shell,
StyleServiceToken,
ThemeServiceToken,
TourServiceToken,
setupShell,
} from '@nanoforge-dev/editor-ui';

Expand Down Expand Up @@ -190,6 +191,9 @@ export const createEditor = async (
services.provide(KeybindingServiceToken, shell.keybindings);
services.provide(StyleServiceToken, shell.styles);
services.provide(ThemeServiceToken, shell.themes);
services.provide(TourServiceToken, shell.tours);
/** `editor.local`: games, git and installs work here (tour steps, menus). */
services.get(CoreServices.ContextKeys).set('editor.local', session.mode === 'OFFLINE');
store.add(registerCorePanels(services.get(CoreServices.Extensions)));
const code = store.add(
followProjectCode({
Expand Down
Loading
Loading