diff --git a/apps/editor/e2e/git.spec.ts b/apps/editor/e2e/git.spec.ts index b9c0b5c3..f2e29039 100644 --- a/apps/editor/e2e/git.spec.ts +++ b/apps/editor/e2e/git.spec.ts @@ -77,24 +77,24 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await showPanel(page, 'Commit'); 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(); + await expect(group(page, 'Unversioned files')).toBeVisible(); git(PONG, 'config', 'user.name', 'E2E'); git(PONG, 'config', 'user.email', 'e2e@example.com'); - const nested = row(page, 'Unversioned Files', 'apps/client/src/main.ts'); + const nested = row(page, 'Unversioned files', 'apps/client/src/main.ts'); await expect(nested).toContainText('main.ts'); await expect(nested).toContainText('apps/client/src'); await message(page).fill('first commit'); await expect(commitButton).toBeDisabled(); - const includeAll = group(page, 'Unversioned Files').getByRole('checkbox', { - name: 'Include all: Unversioned Files', + const includeAll = group(page, 'Unversioned files').getByRole('checkbox', { + name: 'Include all: Unversioned files', includeHidden: true, }); await nested.getByRole('checkbox').check(); expect(await includeAll.evaluate((box: HTMLInputElement) => box.indeterminate)).toBe(true); await includeAll.check(); await commit(page, 'first commit'); - await expect(row(page, 'Unversioned Files', 'package.json')).toHaveCount(0); + await expect(row(page, 'Unversioned files', 'package.json')).toHaveCount(0); await expect(branchStatus(page, 'main')).toBeVisible(); await showPanel(page, 'Git'); @@ -181,23 +181,23 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await page.keyboard.press('Space'); await expect(changed.getByRole('checkbox')).toBeChecked(); await changed.click({ button: 'right' }); - await expect(page.getByRole('menuitem', { name: /^Show Diff/ })).toBeVisible(); - await expect(page.getByRole('menuitem', { name: /^Jump to Source/ })).toBeVisible(); + 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); writeFileSync(join(PONG, NEW_FILE), 'new\n'); - await row(page, 'Unversioned Files', NEW_FILE).click({ button: 'right' }); + await row(page, 'Unversioned files', NEW_FILE).click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Add to VCS' }).click(); await expect(row(page, 'Changes', NEW_FILE)).toBeVisible(); await row(page, 'Changes', NEW_FILE).click(); await rollBack.click(); await page.getByRole('button', { name: 'Rollback', exact: true }).last().click(); - await row(page, 'Unversioned Files', NEW_FILE).click(); + await row(page, 'Unversioned files', NEW_FILE).click(); 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); + await expect(row(page, 'Unversioned files', NEW_FILE)).toHaveCount(0); await vcs(page, 'New branch…'); await page.getByRole('textbox', { name: 'Branch name' }).fill('feature'); @@ -207,7 +207,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await local.getByRole('button', { name: 'main', exact: true }).dblclick(); await expect(branchStatus(page, 'main')).toBeVisible(); await local.getByRole('button', { name: 'feature', exact: true }).click({ button: 'right' }); - await expect(page.getByRole('menuitem', { name: "New Branch from 'feature'…" })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: "New branch from 'feature'…" })).toBeVisible(); 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); @@ -229,7 +229,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash writeFileSync(join(OTHER, FILE), 'theirs\n'); git(OTHER, 'commit', '--quiet', '-am', 'their change'); git(OTHER, 'push', '--quiet'); - await logPanel(page).getByRole('button', { name: 'Fetch All Remotes' }).click(); + await logPanel(page).getByRole('button', { name: 'Fetch all remotes' }).click(); await expect(branchStatus(page, 'main ↓1')).toBeVisible(); const remote = logPanel(page).getByRole('list', { name: 'Branches of origin' }); await remote.getByRole('button', { name: 'main', exact: true }).click(); @@ -248,12 +248,12 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await expect(branchStatus(page, 'main ↑1 ↓1')).toBeVisible(); await vcs(page, 'Update project'); await expect(page.getByText('The update stopped on conflicts').last()).toBeVisible(); - const conflict = row(page, 'Merge Conflicts', FILE); + const conflict = row(page, 'Merge conflicts', FILE); await expect(conflict).toBeVisible(); writeFileSync(join(PONG, FILE), 'both\n'); await conflict.getByRole('button', { name: 'Mark resolved' }).click(); - await expect(group(page, 'Merge Conflicts')).toHaveCount(0); - await commit(page, 'merge', 'Commit and Push…'); + await expect(group(page, 'Merge conflicts')).toHaveCount(0); + await commit(page, 'merge', 'Commit and push…'); const afterMerge = await push(); await expect(afterMerge.getByRole('list', { name: 'Commits to push' })).toContainText('merge'); await afterMerge.getByRole('button', { name: 'Push', exact: true }).click(); @@ -267,7 +267,7 @@ test('git: VCS menu, commit panel, log, branches, remote, conflict, amend, stash await commit(page, 'merge, amended'); await expect(commits(page).getByRole('row').first()).toContainText('merge, amended'); await expect(changed).toHaveCount(0); - await commitPanel(page).getByRole('button', { name: 'Commit Message History' }).click(); + await commitPanel(page).getByRole('button', { name: 'Commit message history' }).click(); await page.getByRole('menuitem', { name: 'my change' }).click(); await expect(message(page)).toHaveValue('my change'); await message(page).fill(''); @@ -299,9 +299,9 @@ test('browses, folds and includes changes from the keyboard', async ({ page }) = await showPanel(page, 'Commit'); await vcs(page, 'Initialize repository'); const files = commitPanel(page).getByRole('group', { name: 'Changed files' }); - const node = commitPanel(page).getByRole('button', { name: /^Unversioned Files, / }); + const node = commitPanel(page).getByRole('button', { name: /^Unversioned files, / }); const includeAll = commitPanel(page).getByRole('checkbox', { - name: 'Include all: Unversioned Files', + name: 'Include all: Unversioned files', includeHidden: true, }); const active = () => @@ -314,7 +314,7 @@ test('browses, folds and includes changes from the keyboard', async ({ page }) = await page.keyboard.press('ArrowDown'); await expect.poll(active).toMatch(/^Changes, 0 files/); await page.keyboard.press('ArrowDown'); - await expect.poll(active).toMatch(/^Unversioned Files, /); + await expect.poll(active).toMatch(/^Unversioned files, /); await page.keyboard.press('Space'); await expect(includeAll).toBeChecked(); await page.keyboard.press('Space'); @@ -324,12 +324,12 @@ test('browses, folds and includes changes from the keyboard', async ({ page }) = await page.keyboard.press('ArrowRight'); await expect(node).toHaveAttribute('aria-expanded', 'true'); await page.keyboard.press('ArrowRight'); - await expect.poll(active).not.toMatch(/^Unversioned Files/); + await expect.poll(active).not.toMatch(/^Unversioned files/); await expect( - group(page, 'Unversioned Files').getByRole('option', { selected: true }), + group(page, 'Unversioned files').getByRole('option', { selected: true }), ).toHaveCount(1); await page.keyboard.press('ArrowLeft'); - await expect.poll(active).toMatch(/^Unversioned Files, /); + await expect.poll(active).toMatch(/^Unversioned files, /); await resetLayout(page); /** The layout is saved after 500 ms. */ await page.waitForTimeout(900); diff --git a/apps/editor/e2e/helpers.ts b/apps/editor/e2e/helpers.ts index eb65c961..09571b6f 100644 --- a/apps/editor/e2e/helpers.ts +++ b/apps/editor/e2e/helpers.ts @@ -1,11 +1,14 @@ import { type Page, expect } from '@playwright/test'; -/** Puts the saved layout of the project back to the default (tests reuse projects). */ +/** + * 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). + */ export const resetLayout = async (page: Page): Promise => { await page.getByRole('button', { name: 'View', exact: true }).click(); await page.getByRole('menuitem', { name: 'Layouts' }).click(); - await page.getByRole('menuitem', { name: 'Reset layout' }).click(); - await expect(page.getByText('Layout reset to default').last()).toBeVisible(); + await page.getByRole('menuitem', { name: 'All panels' }).click(); + await expect(page.getByText('Layout reset with all panels').last()).toBeVisible(); }; /** Expands folders of the Files tree, from the root (already expanded ones stay open). */ @@ -32,6 +35,12 @@ export const panelTab = (page: Page, name: string | RegExp) => */ export const showPanel = async (page: Page, name: string | RegExp): Promise => { const tab = panelTab(page, name); + /** A panel the layout does not show (History, by default) opens from View › Panels. */ + if (typeof name === 'string' && !(await tab.count())) { + await page.getByRole('button', { name: 'View', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Panels' }).click(); + await page.getByRole('menuitem', { name, exact: true }).click(); + } await expect(tab).toBeVisible(); if ((await tab.getAttribute('aria-selected')) !== 'true') await tab.click(); await expect(tab).toHaveAttribute('aria-selected', 'true'); diff --git a/apps/editor/e2e/history-panel.spec.ts b/apps/editor/e2e/history-panel.spec.ts index aee2dbb4..b1bfa580 100644 --- a/apps/editor/e2e/history-panel.spec.ts +++ b/apps/editor/e2e/history-panel.spec.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { openProject } from './helpers'; +import { openProject, resetLayout, showPanel } from './helpers'; import { type Page, expect, test } from './test'; import { PONG } from './workspace'; @@ -14,6 +14,9 @@ const row = (page: Page, name: string) => files(page).getByRole('treeitem', { na const open = async (page: Page) => { await openProject(page, 'pong'); await page.waitForURL(/\/project\//); + await resetLayout(page); + /** History is not shown by default. */ + await showPanel(page, 'History'); await expect(row(page, 'apps')).toBeVisible(); }; diff --git a/apps/editor/e2e/inspectors.spec.ts b/apps/editor/e2e/inspectors.spec.ts index 2fa8f8c6..dd4812a9 100644 --- a/apps/editor/e2e/inspectors.spec.ts +++ b/apps/editor/e2e/inspectors.spec.ts @@ -101,14 +101,16 @@ test.describe('with the engine', () => { /** The count shown; screen readers get it only once a search has settled. */ const count = page.getByText(/of \d+ packets/).first(); + /** + * While hidden (still mounted), the panel records nothing: read its count twice while it is + * hidden, once the packets already on their way have landed. + */ await showPanel(page, 'Console'); - await page.waitForTimeout(1500); - await showPanel(page, 'Network'); + await page.waitForTimeout(500); const before = await count.textContent(); - await showPanel(page, 'Console'); await page.waitForTimeout(2500); - await showPanel(page, 'Network'); expect(await count.textContent()).toBe(before); + await showPanel(page, 'Network'); await expect(page.getByRole('img', { name: 'Bytes per second' })).toBeVisible({ timeout: 10_000, }); diff --git a/apps/editor/e2e/workbench.spec.ts b/apps/editor/e2e/workbench.spec.ts index 321f71d0..cb53ca16 100644 --- a/apps/editor/e2e/workbench.spec.ts +++ b/apps/editor/e2e/workbench.spec.ts @@ -1,4 +1,4 @@ -import { expandFolders, openProject, panelTab, resetLayout } from './helpers'; +import { expandFolders, openProject, panelTab, resetLayout, showPanel } from './helpers'; import { type Locator, type Page, expect, test } from './test'; const openPong = async (page: Page) => { @@ -55,6 +55,7 @@ test('moves, resizes and floats docks, undoes, and restores the layout after rel }) => { await openPong(page); + await showPanel(page, 'History'); await drag(page, panelTab(page, 'History'), panelTab(page, 'Console')); await expect(stackOf(page, 'Console')).toHaveAttribute('data-slot', 'bottom'); await expect(page.locator('[data-slot="bottom"][data-nf-drop="stack"]').first()).toHaveAttribute( @@ -196,3 +197,39 @@ test('the project name in the status bar shows the Project screen, it no longer ); await expect(page).toHaveURL(/\/project\//); }); + +test('a simple layout shows the main panels only, all panels come back from the View menu', async ({ + page, +}) => { + await openPong(page); + const layouts = async (entry: string) => { + await page.getByRole('button', { name: 'View', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Layouts' }).click(); + await page.getByRole('menuitem', { name: entry }).click(); + }; + const stripes = page.getByRole('navigation', { name: /tool windows$/ }); + try { + await layouts('Simple layout'); + await expect(page.getByText('Layout reset with the main panels').last()).toBeVisible(); + for (const name of ['Files', 'Hierarchy', 'Inspector', 'Console']) + await expect(stripes.getByRole('tab', { name, exact: true })).toBeVisible(); + for (const name of ['Commit', 'Profiler', 'History', 'Components']) + await expect(stripes.getByRole('tab', { name, exact: true })).toHaveCount(0); + await layouts('All panels'); + await expect(stripes.getByRole('tab', { name: 'Commit', exact: true })).toBeVisible(); + } finally { + await resetLayout(page); + await page.waitForTimeout(900); + } +}); + +test('says what each screen is for', async ({ page }) => { + await openPong(page); + const screens = page.getByRole('tablist', { name: 'Main screens' }); + await expect( + screens.getByRole('tab', { name: 'Scene', exact: true }), + ).toHaveAccessibleDescription('Your entities drawn in 2D: select and move them.'); + await expect( + screens.getByRole('tab', { name: 'Project', exact: true }), + ).toHaveAccessibleDescription(/apps and shared libraries/); +}); diff --git a/apps/editor/src/lib/editor/error-description.ts b/apps/editor/src/lib/editor/error-description.ts index c5d7be7c..063d0d82 100644 --- a/apps/editor/src/lib/editor/error-description.ts +++ b/apps/editor/src/lib/editor/error-description.ts @@ -1,5 +1,3 @@ -import { RpcError } from '@nanoforge-dev/editor-rpc'; - export interface ErrorAction { readonly label: string; readonly run: () => void | Promise; @@ -14,8 +12,5 @@ export interface ErrorDescription { readonly actions: readonly ErrorAction[]; } -export const errorCode = (reason: unknown): string | undefined => - reason instanceof RpcError ? reason.code : (reason as { code?: string } | null)?.code; - export const errorMessage = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason); diff --git a/apps/editor/src/lib/project/register-core-panels.ts b/apps/editor/src/lib/project/register-core-panels.ts index fc8e64a6..3fb0cf5c 100644 --- a/apps/editor/src/lib/project/register-core-panels.ts +++ b/apps/editor/src/lib/project/register-core-panels.ts @@ -18,6 +18,7 @@ const PANELS = [ { id: 'core.project', title: 'Project', + description: 'Your apps and shared libraries, and what each one uses.', icon: 'layers', kind: 'screen', order: 100, diff --git a/apps/editor/src/lib/workspace/workspace-actions.ts b/apps/editor/src/lib/workspace/workspace-actions.ts index acdc2ad9..620d42ea 100644 --- a/apps/editor/src/lib/workspace/workspace-actions.ts +++ b/apps/editor/src/lib/workspace/workspace-actions.ts @@ -38,6 +38,8 @@ import { import { type AppModel, ProjectsContract } from '@nanoforge-dev/editor-protocol'; import type { RpcClient } from '@nanoforge-dev/editor-rpc'; import type { NotificationService, PromptService } from '@nanoforge-dev/editor-ui'; +/** The notification helpers alone: the kit's main entry brings its Svelte components. */ +import { notifyError } from '@nanoforge-dev/editor-ui/notification'; /** Undo and redo target of the Project screen: changes of the workspace. */ export const PROJECT_HISTORY_CONTEXT = 'project'; @@ -269,8 +271,8 @@ export class WorkspaceActions implements Disposable { detail: 'Its output is in the Console, under Tasks.', }); } catch (error) { - notifications.notify('error', 'Could not install the dependencies', { - detail: error instanceof Error ? error.message : String(error), + notifyError(notifications, 'Could not install the dependencies', error, { + retry: () => this.install(), }); } } diff --git a/apps/editor/src/routes/(editor)/+layout.svelte b/apps/editor/src/routes/(editor)/+layout.svelte index 7dc6e81f..04008872 100644 --- a/apps/editor/src/routes/(editor)/+layout.svelte +++ b/apps/editor/src/routes/(editor)/+layout.svelte @@ -1,10 +1,11 @@