Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
- main
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true

permissions:
contents: read

Expand Down
2 changes: 2 additions & 0 deletions web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 60_000,
globalTimeout: 30 * 60_000,
reporter: [
['list'],
['json', { outputFile: 'output/playwright/results.json' }],
Expand Down
4 changes: 3 additions & 1 deletion web/scripts/check-budgets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ export const BUDGETS = {
modelableWheel: 2 * 1024 * 1024,
application: 750 * 1024,
additionalPython: 15 * 1024 * 1024,
monaco: 1536 * 1024,
aiWorker: 2560 * 1024,
};

export const REPORT_ONLY = ['monaco', 'aiWorker'];
export const REPORT_ONLY = [];

const ENFORCED_CATEGORY_NAMES = Object.keys(BUDGETS);
const CATEGORY_NAMES = [...ENFORCED_CATEGORY_NAMES, ...REPORT_ONLY];
Expand Down
23 changes: 14 additions & 9 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
lazy,
Suspense,
useCallback,
useEffect,
useReducer,
Expand Down Expand Up @@ -52,10 +54,11 @@ import {
} from './ai/provider-state';
import { detectWebGpu, WebGpuProvider } from './ai/webgpu-provider';
import { HeuristicProvider } from './ai/heuristic-provider';
import {
AiPreviewPanel,
type AiPreviewState,
} from './ai/AiPreviewPanel';
import type { AiPreviewState } from './ai/AiPreviewPanel';

const AiPreviewPanel = lazy(() =>
import('./ai/AiPreviewPanel').then((m) => ({ default: m.AiPreviewPanel })),
);
import type {
AiGenerateAction,
AiGenerateParameters,
Expand Down Expand Up @@ -1140,11 +1143,13 @@ export function App({
)}
</section>
{aiPreview !== null ? (
<AiPreviewPanel
preview={aiPreview}
onAccept={handleAiAccept}
onDiscard={handleAiDiscard}
/>
<Suspense fallback={null}>
<AiPreviewPanel
preview={aiPreview}
onAccept={handleAiAccept}
onDiscard={handleAiDiscard}
/>
</Suspense>
) : null}
</section>
<section
Expand Down
13 changes: 7 additions & 6 deletions web/src/budgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ afterEach(async () => {

describe('browser asset budgets', () => {
test('reports named Monaco bundles outside the enforced application budget', () => {
expect(REPORT_ONLY).toEqual(['monaco', 'aiWorker']);
expect(REPORT_ONLY).toEqual([]);
for (const path of [
'assets/monaco-ABC.js',
'assets/editor.worker-ABC.js',
Expand Down Expand Up @@ -91,23 +91,24 @@ describe('browser asset budgets', () => {
modelableWheel: BUDGETS.modelableWheel + 1,
application: BUDGETS.application,
additionalPython: BUDGETS.additionalPython + 42,
monaco: Number.MAX_SAFE_INTEGER,
aiWorker: Number.MAX_SAFE_INTEGER,
monaco: BUDGETS.monaco + 1,
aiWorker: BUDGETS.aiWorker,
};

expect(findViolations(measured)).toEqual([
'modelableWheel',
'additionalPython',
'monaco',
]);
});

test('Monaco size can never hide an enforced application violation', () => {
test('Monaco within budget does not hide an application violation', () => {
const measured = {
modelableWheel: BUDGETS.modelableWheel,
application: BUDGETS.application + 1,
additionalPython: BUDGETS.additionalPython,
monaco: 0,
aiWorker: 0,
monaco: BUDGETS.monaco,
aiWorker: BUDGETS.aiWorker,
};

expect(findViolations(measured)).toEqual(['application']);
Expand Down
1 change: 1 addition & 0 deletions web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,7 @@ select {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

Expand Down
142 changes: 133 additions & 9 deletions web/tests/ai-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';

function modelSource(page: Page) {
Expand All @@ -9,8 +10,8 @@ function sourceOutput(page: Page) {
}

async function waitForReady(page: Page): Promise<void> {
await expect(page.getByRole('status')).toHaveText(/compiler ready/i, {
timeout: 30_000,
await expect(page.locator('main.workbench')).not.toHaveAttribute('data-state', 'loading', {
timeout: 90_000,
});
}

Expand Down Expand Up @@ -40,7 +41,6 @@ async function gotoWithHeuristic(page: Page): Promise<void> {
}

test('activates heuristic AI and shows action buttons', async ({ page }) => {
test.setTimeout(60_000);
await gotoWithHeuristic(page);

await expect(
Expand All @@ -55,7 +55,6 @@ test('activates heuristic AI and shows action buttons', async ({ page }) => {
test('generate entity opens prompt, submits, and shows preview', async ({
page,
}) => {
test.setTimeout(60_000);
await gotoWithHeuristic(page);

await page.getByRole('button', { name: 'Generate entity' }).click();
Expand All @@ -79,7 +78,6 @@ test('generate entity opens prompt, submits, and shows preview', async ({
});

test('explain shows AI explanation preview', async ({ page }) => {
test.setTimeout(60_000);
await gotoWithHeuristic(page);

await page.getByRole('button', { name: 'Explain' }).click();
Expand All @@ -95,7 +93,6 @@ test('explain shows AI explanation preview', async ({ page }) => {
});

test('accept applies generated source to the editor', async ({ page }) => {
test.setTimeout(60_000);
await gotoWithHeuristic(page);

await page.getByRole('button', { name: 'Suggest projection' }).click();
Expand All @@ -108,7 +105,6 @@ test('accept applies generated source to the editor', async ({ page }) => {
});

test('discard closes preview without modifying source', async ({ page }) => {
test.setTimeout(60_000);
await gotoWithHeuristic(page);

const source =
Expand All @@ -126,7 +122,6 @@ test('discard closes preview without modifying source', async ({ page }) => {
});

test('prompt dialog cancels with Cancel button', async ({ page }) => {
test.setTimeout(60_000);
await gotoWithHeuristic(page);

await page.getByRole('button', { name: 'Generate entity' }).click();
Expand All @@ -139,7 +134,6 @@ test('prompt dialog cancels with Cancel button', async ({ page }) => {
});

test('prompt dialog cancels with Escape key', async ({ page }) => {
test.setTimeout(60_000);
await gotoWithHeuristic(page);

await page.getByRole('button', { name: 'Generate entity' }).click();
Expand All @@ -149,3 +143,133 @@ test('prompt dialog cancels with Escape key', async ({ page }) => {
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
});

test('has no accessibility violations across AI toolbar, prompt dialog, and preview', async ({
page,
}) => {
test.setTimeout(90_000);
await gotoWithHeuristic(page);

const toolbarResults = await new AxeBuilder({ page }).analyze();
expect(toolbarResults.violations).toEqual([]);

await page.getByRole('button', { name: 'Generate entity' }).click();
const dialog = page.getByRole('dialog', { name: 'Generate entity' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('textbox')).toBeFocused();

const dialogResults = await new AxeBuilder({ page }).analyze();
expect(dialogResults.violations).toEqual([]);

await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();

await page.getByRole('button', { name: 'Explain' }).click();
await expect(page.getByText('AI explanation')).toBeVisible({
timeout: 10_000,
});

const previewResults = await new AxeBuilder({ page }).analyze();
expect(previewResults.violations).toEqual([]);

await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByText('AI explanation')).toBeHidden();

await page.getByRole('button', { name: 'Suggest projection' }).click();
await expect(page.getByText('AI generated source')).toBeVisible({
timeout: 10_000,
});
await page.getByRole('button', { name: 'Discard' }).click();
await expect(page.getByText('AI generated source')).toBeHidden();
});

test('no CSS animations are active with prefers-reduced-motion', async ({
browser,
}) => {
const context = await browser.newContext({
reducedMotion: 'reduce',
});
const page = await context.newPage();
try {
await page.goto('?test=1&ai=heuristic');
await waitForReady(page);

const animations = await page.evaluate(() => {
const active: { element: string; duration: string }[] = [];
for (const el of document.querySelectorAll('*')) {
const style = getComputedStyle(el);
const animDuration = parseFloat(style.animationDuration);
const transDuration = parseFloat(style.transitionDuration);
if (animDuration > 0.02) {
active.push({
element: el.tagName + (el.className ? `.${el.className}` : ''),
duration: style.animationDuration,
});
}
if (transDuration > 0.02) {
active.push({
element: el.tagName + (el.className ? `.${el.className}` : ''),
duration: style.transitionDuration,
});
}
}
return active;
});
expect(animations).toEqual([]);
} finally {
await context.close();
}
});

test('no main-thread task exceeds 100ms during standard editing', async ({
page,
}) => {
test.setTimeout(90_000);
await page.goto('?test=1');
await waitForReady(page);

const longTasks = await page.evaluate(async () => {
const tasks: { duration: number; name: string }[] = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
tasks.push({ duration: entry.duration, name: entry.name });
}
});
observer.observe({ type: 'longtask', buffered: false });

const editor = document.querySelector(
'.source-editor .view-lines',
) as HTMLElement | null;
editor?.click();
const input = document.querySelector(
'[aria-label^="Model source"]',
) as HTMLElement | null;
input?.focus();

for (const char of 'domain test { owner: "team" }') {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: char, bubbles: true }),
);
await new Promise((r) => setTimeout(r, 20));
}

const validateButton = Array.from(
document.querySelectorAll('button'),
).find((b) => b.textContent?.includes('Validate'));
validateButton?.click();
await new Promise((r) => setTimeout(r, 500));

const formatButton = Array.from(
document.querySelectorAll('button'),
).find((b) => b.textContent?.includes('Format'));
formatButton?.click();
await new Promise((r) => setTimeout(r, 500));

observer.disconnect();
return tasks;
});

for (const task of longTasks) {
expect(task.duration).toBeLessThanOrEqual(100);
}
});
10 changes: 5 additions & 5 deletions web/tests/conformance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ test('browser compiler matches native snapshots including cross-file references'
page,
}) => {
await page.goto('?test=1');
await expect(page.getByRole('status')).toHaveText(/compiler ready/i, {
timeout: 30_000,
await expect(page.locator('main.workbench')).not.toHaveAttribute('data-state', 'loading', {
timeout: 90_000,
});

let workspaceRevision = 100;
Expand Down Expand Up @@ -503,7 +503,7 @@ test('browser compiler stays within initialization and operation budgets', async
browser,
browserName,
}, testInfo) => {
test.setTimeout(180_000);
test.setTimeout(600_000);
const cold = await measureColdInitializations(browser);
const cachedContext = await browser.newContext();
const finishCachedRequestAudit = startLocalRequestAudit(cachedContext);
Expand Down Expand Up @@ -716,8 +716,8 @@ async function initializePage(page: Page): Promise<void> {
}

async function waitForCompiler(page: Page): Promise<void> {
await expect(page.getByRole('status')).toHaveText(/compiler ready/i, {
timeout: 30_000,
await expect(page.locator('main.workbench')).not.toHaveAttribute('data-state', 'loading', {
timeout: 90_000,
});
}

Expand Down
Loading