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
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/02-tooling_bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ body:
label: Which tooling is this bug report for?
options:
- example-plugin
- example-theme
- vite-plugin
- other
validations:
Expand Down
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/03-feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ body:
- settings-ui
- viewport
- example-plugin
- example-theme
- vite-plugin
- other
validations:
Expand Down
3 changes: 3 additions & 0 deletions .github/issue-labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,8 @@ plugins:viewport:
tooling:example-plugin:
- "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nexample-plugin\\n"

tooling:example-theme:
- "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nexample-theme\\n"

tooling:vite-plugin:
- "### Which (package|tooling|package or tooling) is this (bug report|feature request) for\\?\\n\\nvite-plugin\\n"
6 changes: 6 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ tooling:example-plugin:
- tooling/example-plugin/*
- tooling/example-plugin/**/*

tooling:example-theme:
- changed-files:
- any-glob-to-any-file:
- tooling/example-theme/*
- tooling/example-theme/**/*

tooling:vite-plugin:
- changed-files:
- any-glob-to-any-file:
Expand Down
4 changes: 4 additions & 0 deletions .github/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@
description: 'Related to Example plugin tooling'
color: '606bdf'

- name: 'tooling:example-theme'
description: 'Related to Example theme tooling'
color: '606bdf'

- name: 'tooling:vite-plugin'
description: 'Related to Vite plugin tooling'
color: '606bdf'
Expand Down
98 changes: 98 additions & 0 deletions apps/editor/e2e/adoption.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

import { openProject, resetLayout } from './helpers';
import { type Page, expect, test } from './test';
import { PONG } from './workspace';

const APPS = join(PONG, 'apps');
const COMPONENTS = join(APPS, 'client/src/components');

/** Every file under a folder, with its bytes (images too: not as text). */
const snapshot = (dir = APPS): Map<string, string> =>
new Map(
readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
return statSync(path).isDirectory()
? [...snapshot(path)]
: [[path, readFileSync(path).toString('base64')] as const];
}),
);

let original: Map<string, string>;
test.beforeEach(() => {
original = snapshot();
});
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);
});

const report = (page: Page) => page.getByRole('region', { name: 'Adapt to the editor' });
/** The report starts collapsed. */
const expand = async (page: Page) =>
report(page).getByRole('button', { name: 'Show suggestions' }).click({ timeout: 30_000 });
const card = (page: Page, title: string, app: string) =>
report(page).getByRole('listitem', { name: title }).filter({ hasText: app });

test('the report splits a file of components, previewed, then undone in one step', async ({
page,
}) => {
await openProject(page, 'pong');
await page.waitForURL(/\/project\//);
await resetLayout(page);
await page.getByRole('tab', { name: 'Project', exact: true }).click();
await expand(page);

await expect(card(page, 'Register the editor library', 'pong-network-client')).toBeVisible({
timeout: 30_000,
});
const split = card(page, 'Split components.ts', 'pong-network-client');
await expect(split).toBeVisible({ timeout: 30_000 });
await split.getByRole('button', { name: 'Split into one file each…' }).click();

const preview = page.getByRole('dialog', { name: 'Split components.ts' });
await expect(
preview.getByRole('region', { name: 'apps/client/src/components/position.ts' }),
).toContainText('New');
await expect(
preview.getByRole('region', { name: 'apps/client/src/components/components.ts' }),
).toContainText('Removed');
expect(existsSync(join(COMPONENTS, 'position.ts'))).toBe(false);
await preview.getByRole('button', { name: 'Apply' }).click();

await expect.poll(() => existsSync(join(COMPONENTS, 'components.ts'))).toBe(false);
expect(readFileSync(join(COMPONENTS, 'position.ts'), 'utf8')).toContain('export class Position');
expect(readFileSync(join(APPS, 'client/src/systems/systems.ts'), 'utf8')).toContain(
'from "../components/position"',
);
await expect(split).toHaveCount(0, { timeout: 15_000 });
await expect(
card(page, 'circle-component.ts imports main.ts', 'pong-network-client'),
).toBeVisible();

await page.locator('.overview').click({ position: { x: 4, y: 4 } });
await page.keyboard.press('Control+z');
await expect.poll(() => snapshot()).toEqual(original);
});

test('a suggestion is dismissed for the project', async ({ page }) => {
await openProject(page, 'pong');
await page.waitForURL(/\/project\//);
await page.getByRole('tab', { name: 'Project', exact: true }).click();
await expand(page);
const install = report(page).getByRole('listitem', { name: 'Install the dependencies' });
await expect(install).toBeVisible({ timeout: 30_000 });
await install.getByRole('button', { name: 'Dismiss' }).click();
await expect(install).toHaveCount(0);
await page.waitForTimeout(900);
await page.reload();
await page.getByRole('tab', { name: 'Project', exact: true }).click();
await expand(page);
await expect(card(page, 'Register the editor library', 'pong-network-client')).toBeVisible({
timeout: 30_000,
});
await expect(install).toHaveCount(0);
});
86 changes: 86 additions & 0 deletions apps/editor/e2e/appearance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { openProject, resetLayout, showPanel } from './helpers';
import { type Page, expect, test } from './test';

const openSettings = async (page: Page) => {
await page.locator('[data-nf-part="screen"]').click();
await page.keyboard.press('Control+Alt+s');
const dialog = page.getByRole('dialog', { name: 'Settings' });
await dialog.getByRole('treeitem', { name: 'Appearance' }).click();
return dialog;
};
const variable = (page: Page, name: string) =>
page.evaluate(
(token) => getComputedStyle(document.documentElement).getPropertyValue(token).trim(),
name,
);

/** Puts the theme, the density and the icons back (account settings outlive the test). */
const restore = async (page: Page) => {
const open = page.getByRole('dialog', { name: 'Settings' });
if (await open.isVisible()) await open.getByRole('button', { name: 'Cancel' }).click();
await expect(open).toBeHidden();
const dialog = await openSettings(page);
const panel = dialog.getByRole('region', { name: 'Appearance' });
await panel.getByRole('radio', { name: 'NanoForge Dark' }).click();
await panel.getByRole('radio', { name: 'Normal' }).click();
await panel.getByRole('button', { name: 'Icon theme' }).click();
await page.getByRole('option', { name: 'NanoForge', exact: true }).click();
await dialog.getByRole('button', { name: 'OK' }).click();
await expect(dialog).toBeHidden();
await page.waitForTimeout(900);
};

test('themes, density and icons from a plugin, chosen in Settings › Appearance', async ({
page,
}) => {
test.setTimeout(90_000);
await openProject(page, 'pong');
await page.waitForURL(/\/project\//);
await resetLayout(page);
try {
const dialog = await openSettings(page);
const panel = dialog.getByRole('region', { name: 'Appearance' });
const themes = panel.getByRole('radiogroup', { name: 'Theme' });
await expect(themes.getByRole('radio', { name: 'NanoForge Dark' })).toHaveAttribute(
'aria-checked',
'true',
);
await themes.getByRole('radio', { name: 'Ocean' }).click();
await panel.getByRole('radio', { name: 'Compact' }).click();
await panel.getByRole('button', { name: 'Icon theme' }).click();
await page.getByRole('option', { name: 'Ocean icons' }).click();
await dialog.getByRole('button', { name: 'Apply' }).click();

await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.nfTheme))
.toBe('acme-ocean');
expect(await variable(page, '--nf-color-accent')).toBe('#176aa8');
expect(await variable(page, '--nf-color-text')).toBe('#d8dde7');
expect(await variable(page, '--nf-radius-control')).toBe('4px');
expect(await variable(page, '--nf-control-height')).toBe('22px');

await panel.getByRole('button', { name: 'Get more themes' }).click();
const plugins = dialog.getByRole('region', { name: 'Plugins' });
await expect(plugins.getByRole('textbox', { name: 'Search plugins' })).toHaveValue(
'keywords:theme',
);
await expect(plugins.getByText('@acme/sunset').first()).toBeVisible();
await expect(plugins.getByText('@acme/badge')).toHaveCount(0);
/** The first result's details load and render: close once they are there. */
await expect(plugins.getByRole('button', { name: 'Install' })).toBeVisible();
await dialog.getByRole('button', { name: 'OK' }).click();
await expect(dialog).toBeHidden();

await showPanel(page, 'Files');
const tree = page.getByRole('tree', { name: 'Project files' });
await expect(tree.getByRole('treeitem', { name: 'apps' }).locator('.nf-icon-file')).toHaveCount(
1,
);
} finally {
await restore(page);
}
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.nfTheme))
.toBe('nanoforge-dark');
expect(await variable(page, '--nf-control-height')).toBe('26px');
});
17 changes: 17 additions & 0 deletions apps/editor/e2e/prepare-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ for (const [path, content] of Object.entries(files)) {
writeFileSync(join(fixture, path), content);
}

/** The example theme has no code: its build copies its manifest and icons into dist/. */
execFileSync('node', ['build.mjs'], {
cwd: join(import.meta.dirname, '../../../tooling/example-theme'),
stdio: 'ignore',
});

const registry = {
'@acme/badge/1.0.0/nanoforge.manifest.json': JSON.stringify({
type: 'plugin',
Expand Down Expand Up @@ -108,6 +114,17 @@ const registry = {
dependencies: { '@acme/shapes': '^1.0.0' },
}),
'@acme/render/1.0.0/README.md': 'Draws the shapes of @acme/shapes.\n',
'@acme/sunset/1.0.0/nanoforge.manifest.json': JSON.stringify({
type: 'plugin',
name: '@acme/sunset',
version: '1.0.0',
description: 'A warm light theme.',
keywords: ['theme'],
engines: { editor: '>=0.1.0' },
contributes: {
themes: [{ id: 'acme-sunset', label: 'Sunset', extends: 'nanoforge-light', colors: {} }],
},
}),
};
for (const [path, content] of Object.entries(registry)) {
mkdirSync(dirname(join(root, '.registry', path)), { recursive: true });
Expand Down
17 changes: 17 additions & 0 deletions apps/editor/e2e/project-plugins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ test("loads the project's plugins and names the plugins its packages suggest", a
await page.keyboard.press('Escape');
});

test('installs a suggested plugin for the project from the notification', async ({ page }) => {
write(join(PACKAGES, '@acme/motion/nanoforge.manifest.json'), {
type: 'package',
name: '@acme/motion',
version: '1.0.0',
suggestedPlugins: ['@acme/badge'],
});
await openProject(page, 'pong');
await page.waitForURL(/\/project\//);
await expect(page.getByText('Packages in this project suggest a plugin')).toBeVisible();
await page.getByRole('button', { name: 'Install for this project' }).click();
await expect
.poll(() => existsSync(join(PROJECT_PLUGINS, '@acme/badge/nanoforge.manifest.json')))
.toBe(true);
await expect(page.getByText('Plugins installed: reload the editor to finish')).toBeVisible();
});

test("runs a project plugin's server entry while the project is open", async ({ page }) => {
const closeProject = async () => {
await page.getByRole('button', { name: 'File', exact: true }).click();
Expand Down
6 changes: 5 additions & 1 deletion apps/editor/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ export default defineConfig({
DATA_DIR: join(root, 'e2e', WORKSPACE, '.data'),
E2E_WORKSPACE: WORKSPACE,
REGISTRY_DIR: join(root, 'e2e', WORKSPACE, '.registry'),
DEV_PLUGINS: [join(root, '../../tooling/example-plugin'), process.env.E2E_EXTRA_PLUGINS]
DEV_PLUGINS: [
join(root, '../../tooling/example-plugin'),
join(root, '../../tooling/example-theme'),
process.env.E2E_EXTRA_PLUGINS,
]
.filter(Boolean)
.join(','),
...(process.env.NANOFORGE_ENGINE && { NANOFORGE_ENGINE: process.env.NANOFORGE_ENGINE }),
Expand Down
Loading
Loading