sure
This commit is contained in:
parent
ced0ac56f3
commit
d6a8f898d1
27 changed files with 751 additions and 354 deletions
312
e2e/app.spec.ts
312
e2e/app.spec.ts
|
|
@ -1,5 +1,34 @@
|
|||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const canvasName = 'Interactive generative garden canvas';
|
||||
|
||||
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 disableWebGpu = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
|
|
@ -9,223 +38,98 @@ const disableWebGpu = async (page: Page) => {
|
|||
});
|
||||
};
|
||||
|
||||
const getFirstSwatchColor = (page: Page) =>
|
||||
page
|
||||
.locator('.color-swatch')
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
|
||||
const getGardenBackground = (page: Page) =>
|
||||
page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue('--garden-background').trim()
|
||||
);
|
||||
|
||||
test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => {
|
||||
const browserFailures: Array<string> = [];
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const failure = request.failure();
|
||||
browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`);
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
browserFailures.push(`${response.status()} ${response.url()}`);
|
||||
test('starts the WebGPU garden and accepts drawing input', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(page);
|
||||
const consoleErrors: Array<string> = [];
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await disableWebGpu(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('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page).toHaveTitle('Fleeting Garden');
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||
|
||||
await page.getByRole('button', { name: 'About' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).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);
|
||||
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps fallback controls interactive and accessible', async ({ page }) => {
|
||||
await disableWebGpu(page);
|
||||
test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => {
|
||||
const browserFailures = collectLocalBrowserFailures(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 aboutButton = page.getByRole('button', { name: 'About' });
|
||||
const aboutPanel = page.locator('#info-panel');
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await aboutButton.click();
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(aboutPanel).not.toHaveAttribute('inert', '');
|
||||
await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(aboutButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(aboutPanel).toHaveAttribute('inert', '');
|
||||
|
||||
const settingsButton = page.locator('button.settings');
|
||||
await expect(settingsButton).toHaveAttribute('aria-label', 'Show config overlay');
|
||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await settingsButton.click();
|
||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(settingsButton).toHaveAttribute('aria-label', 'Hide config overlay');
|
||||
await expect(page.locator('.config-pane')).toBeVisible();
|
||||
await expect(page.locator('.config-pane')).toContainText('Runtime');
|
||||
await expect(page.locator('.color-reaction-matrix')).toBeVisible();
|
||||
|
||||
const colorReaction = page.getByLabel('Color 1 agents reacting to color 2');
|
||||
await colorReaction.selectOption('-1');
|
||||
await expect(colorReaction).toHaveValue('-1');
|
||||
await settingsButton.click();
|
||||
await expect(settingsButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
const soundButton = page.locator('button.sound');
|
||||
const volumeSlider = page.getByLabel('Master volume');
|
||||
await expect(volumeSlider).toHaveValue('0.42');
|
||||
await volumeSlider.evaluate((input) => {
|
||||
const slider = input as HTMLInputElement;
|
||||
slider.value = '0.25';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect(volumeSlider).toHaveValue('0.25');
|
||||
await expect(volumeSlider).toHaveAttribute('aria-valuetext', '25%');
|
||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'false');
|
||||
await soundButton.click();
|
||||
await expect(soundButton).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio');
|
||||
await page.reload();
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByLabel('Master volume')).toHaveValue('0.25');
|
||||
await expect(page.getByLabel('Master volume')).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
'Muted, 25%'
|
||||
);
|
||||
|
||||
const initialSwatchColor = await getFirstSwatchColor(page);
|
||||
const initialBackground = await getGardenBackground(page);
|
||||
await page.getByRole('button', { name: 'Next vibe' }).click();
|
||||
await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor);
|
||||
await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground);
|
||||
|
||||
await page.getByRole('button', { name: 'Draw colour 2' }).click();
|
||||
await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/);
|
||||
await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/);
|
||||
|
||||
const mirrorSlider = page.locator('.mirror-segment-slider');
|
||||
await mirrorSlider.evaluate((input) => {
|
||||
const slider = input as HTMLInputElement;
|
||||
slider.value = '3';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await expect(page.locator('.mirror-segment-control')).toHaveAttribute(
|
||||
'title',
|
||||
'3 thirds'
|
||||
);
|
||||
await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('keeps the fallback shell usable on mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('body')).not.toHaveClass(/is-loading/);
|
||||
|
||||
const canvasBox = await page
|
||||
.getByRole('img', { name: 'Interactive generative garden canvas' })
|
||||
.boundingBox();
|
||||
expect(canvasBox?.width).toBeGreaterThan(0);
|
||||
expect(canvasBox?.height).toBeGreaterThan(0);
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU');
|
||||
|
||||
const aboutButtonReceivesPointer = await page
|
||||
.getByRole('button', { name: 'About' })
|
||||
.evaluate((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const target = document.elementFromPoint(
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2
|
||||
);
|
||||
|
||||
return button === target || button.contains(target);
|
||||
});
|
||||
|
||||
expect(aboutButtonReceivesPointer).toBe(true);
|
||||
});
|
||||
|
||||
test('hides the bottom dock after the cursor leaves fullscreen controls', async ({
|
||||
page,
|
||||
}) => {
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||
.toBe(true);
|
||||
|
||||
await page.mouse.move(640, 120);
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
|
||||
await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, {
|
||||
timeout: 6000,
|
||||
});
|
||||
await expect(page.locator('.garden-controls')).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('aside.control-dock')
|
||||
.evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight)
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page.mouse.move(640, 700);
|
||||
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
|
||||
await expect(page.locator('.garden-controls')).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('aside.control-dock')
|
||||
.evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight)
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => {
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
await disableWebGpu(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Enter fullscreen' }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||
.toBe(true);
|
||||
|
||||
await page.mouse.move(195, 120);
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(5200);
|
||||
|
||||
await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/);
|
||||
await expect(page.getByRole('button', { name: 'About' })).toBeVisible();
|
||||
const fallback = page.getByRole('alert');
|
||||
await expect(fallback).toContainText('Fleeting Garden needs WebGPU');
|
||||
await expect(fallback).toContainText('webgpu-unsupported');
|
||||
expect(browserFailures).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue