Update project tooling

This commit is contained in:
Andras Schmelczer 2026-05-24 10:57:14 +01:00
parent f0fb4fc86b
commit 3c21291d72
14 changed files with 1469 additions and 198 deletions

View file

@ -1,5 +0,0 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View file

@ -1,4 +1,4 @@
name: Deploy to Pages
name: Check & deploy
on:
push:
@ -25,20 +25,38 @@ jobs:
cache: 'npm'
- name: Install dependencies
run: npm ci
run: |
npm ci
npx playwright install --with-deps chromium
- name: Lint
run: npm run lint -- --check || true
- name: Test
run: |
npm run lint:check
npm run format:check
npm run typecheck
npm run typecheck:e2e
npm test
- name: Typecheck
run: npm run typecheck
- name: Test E2E
run: |
npm run test:e2e
- name: Build
run: npm run build
run: |
npm run build
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
- name: Copy build to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
apt update && apt install -y rsync
mkdir -p /pages
rsync -a --delete dist/ /pages/fleeting-garden
rsync -a --delete dist/ /pages/fleeting

44
.gitignore vendored
View file

@ -1,45 +1,5 @@
# Dependency directory
node_modules
modules/
ts-node--*/
rss.xml
dist
# Logs
logs
test-results
.DS_Store
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
*.ssh
*.ppk
v8-compile-cache-0/
Thumbs.db
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
bin
ts-node
# Personal Scripts
*.bat
*.ssh
*.sh
!system.min.js
# Editors
.vscode
.markdownlint.json
# Build Files
temp
*.js
*.map
!webpack.*

2
.nvmrc
View file

@ -1 +1 @@
22
22.13.0

View file

@ -6,5 +6,5 @@
"endOfLine": "lf",
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": ["<BUILTIN_MODULES>", "<THIRD_PARTY_MODULES>", "", "^[./]"],
"importOrderTypeScriptVersion": "5.0.0"
"importOrderTypeScriptVersion": "6.0.3"
}

4
definitions.d.ts vendored
View file

@ -1,4 +0,0 @@
declare module '*.wgsl?raw' {
const content: string;
export default content;
}

265
e2e/app.spec.ts Normal file
View file

@ -0,0 +1,265 @@
import { test as base, expect, type Page } from '@playwright/test';
const canvasName = 'Interactive generative garden canvas';
interface BrowserDiagnostics {
browserFailures: Array<string>;
consoleErrors: Array<string>;
}
const isLocalUrl = (url: string) => {
const { hostname } = new URL(url);
return hostname === '127.0.0.1' || hostname === 'localhost';
};
const collectLocalBrowserFailures = (page: Page) => {
const failures: Array<string> = [];
page.on('requestfailed', (request) => {
if (!isLocalUrl(request.url())) {
return;
}
const failure = request.failure();
failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
});
page.on('response', (response) => {
if (response.status() < 400 || !isLocalUrl(response.url())) {
return;
}
failures.push(`${response.status()} ${response.url()}`);
});
return failures;
};
const test = base.extend<{ browserDiagnostics: BrowserDiagnostics }>({
browserDiagnostics: [
async ({ page }, use) => {
const browserFailures = collectLocalBrowserFailures(page);
const consoleErrors: Array<string> = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await use({ browserFailures, consoleErrors });
expect(consoleErrors).toEqual([]);
expect(browserFailures).toEqual([]);
},
{ auto: true },
],
});
const disableWebGpu = async (page: Page) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'gpu', {
configurable: true,
value: undefined,
});
});
};
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
await page.addInitScript((expectedCanvasName) => {
const captureState = { count: 0 };
Object.defineProperty(window, '__fleetingGardenPointerCaptures', {
configurable: true,
value: captureState,
});
const originalSetPointerCapture = Element.prototype.setPointerCapture;
Element.prototype.setPointerCapture = function setPointerCapture(pointerId) {
if (
this instanceof HTMLCanvasElement &&
this.getAttribute('aria-label') === expectedCanvasName
) {
captureState.count += 1;
}
return originalSetPointerCapture.call(this, pointerId);
};
}, canvasName);
await page.goto('/');
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
await expect(startButton).toBeVisible();
await expect(startButton).toBeEnabled({ timeout: 30_000 });
await page.keyboard.press('Enter');
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000,
});
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
const canvas = page.getByRole('img', { name: canvasName });
await expect(canvas).toBeVisible();
const canvasSize = await canvas.evaluate((element) => {
const canvasElement = element as HTMLCanvasElement;
return {
height: canvasElement.height,
width: canvasElement.width,
};
});
expect(canvasSize.width).toBeGreaterThan(0);
expect(canvasSize.height).toBeGreaterThan(0);
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (!box) {
return;
}
await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, {
steps: 16,
});
await page.mouse.up();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as unknown as {
__fleetingGardenPointerCaptures?: { count: number };
}
).__fleetingGardenPointerCaptures?.count ?? 0
)
)
.toBeGreaterThan(0);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as unknown as {
__fleetingGardenBrushPasses?: number;
}
).__fleetingGardenBrushPasses ?? 0
)
)
.toBeGreaterThan(0);
});
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
await disableWebGpu(page);
await page.goto('/');
await expect(page).toHaveTitle('Fleeting Garden');
await expect(page.getByRole('img', { name: canvasName })).toBeVisible();
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
const fallback = page.getByRole('alert');
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
await expect(fallback).toContainText('webgpu-unsupported');
});
test('syncs the selected vibe with the URI', async ({ page }) => {
await disableWebGpu(page);
await page.goto('/?vibe=Aurora%20Mycelium');
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
await page.getByRole('button', { name: 'Next vibe' }).click();
await expect(page).toHaveURL(/vibe=velvet-observatory/);
await page.goBack();
await expect(page).toHaveURL(/vibe=aurora-mycelium/);
});
test('keeps audio focus outlines scoped to the active control', async ({ page }) => {
await disableWebGpu(page);
await page.goto('/');
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
const audioControl = page.locator('.audio-control');
const soundButton = page.getByRole('button', { name: /audio/i });
const volumeSlider = page.getByRole('slider', { name: 'Master volume' });
await soundButton.click();
await expect(audioControl).toHaveCSS('outline-style', 'none');
await expect(soundButton).toHaveCSS('outline-style', 'none');
await page.mouse.click(10, 10);
for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) {
await page.keyboard.press('Tab');
const activeClass = await page.evaluate(() =>
String(document.activeElement?.className ?? '')
);
if (activeClass.includes('sound')) {
break;
}
}
await expect(soundButton).toBeFocused();
await expect(soundButton).toHaveCSS('outline-style', 'solid');
await expect(soundButton).toHaveCSS('outline-offset', '-4px');
await page.keyboard.press('Tab');
await expect(volumeSlider).toBeFocused();
await expect(volumeSlider).toHaveCSS('outline-style', 'solid');
await expect(volumeSlider).toHaveCSS('outline-offset', '-4px');
});
test('keeps the config overlay scrollable and dismissible on mobile', async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 640 });
await page.goto('/');
const startButton = page.getByRole('button', { exact: true, name: 'Start' });
await expect(startButton).toBeEnabled({ timeout: 30_000 });
await startButton.click();
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
timeout: 30_000,
});
const settingsButton = page.getByRole('button', { name: 'Show config overlay' });
await settingsButton.click();
const pane = page.locator('.config-pane');
const closeButton = page.locator('.config-pane-close');
await expect(pane).toBeVisible();
await expect(closeButton).toBeVisible();
const paneMetrics = await pane.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return {
bottom: rect.bottom,
clientHeight: element.clientHeight,
overflowY: style.overflowY,
scrollHeight: element.scrollHeight,
top: rect.top,
viewportHeight: window.innerHeight,
viewportWidth: window.innerWidth,
width: rect.width,
};
});
expect(paneMetrics.top).toBeGreaterThanOrEqual(0);
expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight);
expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8));
expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight);
expect(['auto', 'scroll']).toContain(paneMetrics.overflowY);
await pane.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect
.poll(() => pane.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
await closeButton.click();
await expect(pane).toBeHidden();
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
});

30
eslint.config.js Normal file
View file

@ -0,0 +1,30 @@
import js from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['node_modules/**', 'dist/**', 'public/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
prettierConfig,
{
files: ['**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'prefer-const': 'error',
},
}
);

1174
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,28 @@
{
"name": "webgpu-seed",
"name": "fleeting-garden",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "A WebGPU-powered slime-mold-meets-territory-control simulation.",
"description": "Tend it while you can. The garden returns to weather either way. A WebGPU drawing toy in your browser.",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.{ts,scss,json,html}\"",
"lint:check": "eslint . && npm run unused:check",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"format:check": "prettier --check \"index.html\" \"public/manifest.webmanifest\" \"src/**/*.{ts,scss,json,html}\" \"e2e/**/*.ts\" \".forgejo/workflows/*.yml\" \"*.{json,js,ts,md}\" \".prettierrc\"",
"typecheck": "tsc --noEmit",
"typecheck:e2e": "tsc --noEmit --project tsconfig.playwright.json",
"test": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"generate-icons": "pwa-assets-generator",
"update": "ncu"
"unused:check": "knip --production --files --dependencies && knip --exports --include-entry-exports",
"generate-icons": "pwa-assets-generator"
},
"engines": {
"node": ">=20"
"node": ">=22.13.0"
},
"repository": {
"type": "git",
@ -33,23 +39,28 @@
"browserslist": [
"supports webgpu and last 2 years"
],
"dependencies": {
"gl-matrix": "^3.4.4"
"knip": {
"ignoreFiles": [
"pwa-assets.config.ts"
]
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@playwright/test": "^1.60.0",
"@tweakpane/core": "~2.0.5",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@webgpu/types": "^0.1.69",
"browserslist": "^4.28.2",
"browserslist-to-esbuild": "^2.1.1",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"gl-matrix": "^3.4.4",
"globals": "^17.6.0",
"knip": "^6.14.1",
"lightningcss": "^1.32.0",
"npm-check-updates": "^22.1.0",
"prettier": "^3.8.3",
"sass": "^1.99.0",
"typescript": "^6.0.3",
@ -57,5 +68,9 @@
"vite": "^8.0.10",
"vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.5"
},
"dependencies": {
"@plausible-analytics/tracker": "^0.4.5",
"tweakpane": "~4.0.5"
}
}

37
playwright.config.ts Normal file
View file

@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
const port = 4173;
const baseURL = `https://127.0.0.1:${port}`;
const isCi = Boolean(process.env.CI);
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: isCi,
retries: isCi ? 2 : 0,
workers: 1,
reporter: isCi ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL,
ignoreHTTPSErrors: true,
trace: 'on-first-retry',
},
webServer: {
command: `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`,
ignoreHTTPSErrors: true,
reuseExistingServer: false,
timeout: 120_000,
url: baseURL,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: ['--enable-unsafe-webgpu'],
},
},
},
],
});

View file

@ -9,14 +9,13 @@
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*", "definitions.d.ts", "vite.config.ts"]
"include": ["src/**/*", "pwa-assets.config.ts", "vite.config.ts"]
}

7
tsconfig.playwright.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["playwright.config.ts", "e2e/**/*.ts"]
}

View file

@ -1,12 +1,15 @@
import basicSsl from '@vitejs/plugin-basic-ssl';
import browserslist from 'browserslist';
import browserslistToEsbuild from 'browserslist-to-esbuild';
import { browserslistToTargets } from 'lightningcss';
import { defineConfig } from 'vitest/config';
import { viteSingleFile } from 'vite-plugin-singlefile';
import { defineConfig } from 'vitest/config';
const cssTargets = browserslistToTargets(browserslist());
const esbuildTargets = browserslistToEsbuild();
export default defineConfig({
plugins: [viteSingleFile()],
export default defineConfig(({ command }) => ({
plugins: [viteSingleFile(), ...(command === 'serve' ? [basicSsl()] : [])],
css: {
transformer: 'lightningcss',
lightningcss: {
@ -14,16 +17,14 @@ export default defineConfig({
},
},
build: {
target: 'es2022',
cssCodeSplit: false,
target: esbuildTargets,
cssMinify: 'lightningcss',
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
},
server: {
open: true,
host: true,
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
}));