1 parent
3cfee2451d
commit
c0c79b0bb6
6 files changed
+138
-17
No files matched your search
@@ -9,6 +9,7 @@
|
||||
"check": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test:ui:accessibility": "node tests/app-shell-accessibility-ui.cjs",
|
||||
"test:ui:api-provider-dialog": "node tests/api-provider-dialog-layout-ui.cjs",
|
||||
"screenshots": "node scripts/capture-readme-screenshots.cjs",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
closestCenter,
|
||||
@@ -2049,7 +2050,7 @@ export function ApiProviderDialog({
|
||||
? t('apiAccess.models.autoHint')
|
||||
: t('apiAccess.models.allHint');
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<>
|
||||
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !busy && onClose()}>
|
||||
<form ref={providerDialogRef} className="config-dialog management-dialog api-provider-dialog" role="dialog" aria-modal="true" aria-labelledby="api-provider-dialog-title" onSubmit={(event) => void submit(event)}>
|
||||
@@ -2257,4 +2258,6 @@ export function ApiProviderDialog({
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
// Keep both dialogs outside the page's query container, which can contain fixed elements in WebKit.
|
||||
return typeof document === 'undefined' ? content : createPortal(content, document.body);
|
||||
}
|
||||
+7
-8
@@ -5108,17 +5108,16 @@ textarea:focus-visible {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.real-provider-workbench + .config-dialog-backdrop .management-dialog {
|
||||
grid-template-rows: auto repeat(5, minmax(52px, auto)) auto;
|
||||
height: auto;
|
||||
max-height: min(720px, calc(100vh - 48px));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.real-provider-workbench + .config-dialog-backdrop .api-provider-dialog {
|
||||
.config-dialog.api-provider-dialog {
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: auto;
|
||||
width: min(560px, 100%);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: min(720px, calc(100vh - 48px));
|
||||
max-height: min(720px, calc(100dvh - 48px));
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
gap: 10px;
|
||||
padding: 16px 18px;
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
(async () => {
|
||||
const { createServer } = await import('vite');
|
||||
const react = (await import('@vitejs/plugin-react')).default;
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
root: path.resolve(__dirname, '..'),
|
||||
plugins: [react()],
|
||||
optimizeDeps: { entries: ['tests/fixtures/provider-duplicates.html'] },
|
||||
logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 1421, strictPort: false, watch: null },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
await (await fetch(base)).text();
|
||||
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
|
||||
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
|
||||
await page.goto(`${base}/tests/fixtures/provider-duplicates.html`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.provider-category-panel button').filter({ hasText: 'Claude' }).click();
|
||||
await page.locator('.real-provider-row').waitFor();
|
||||
await page.evaluate(() => {
|
||||
window.providerFixture.records[0].models = Array.from({ length: 30 }, (_, index) => ({ name: `model-${index}` }));
|
||||
});
|
||||
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
const form = page.getByRole('dialog', { name: 'Edit API Connection', exact: true });
|
||||
assert.equal(await form.locator('.model-config-entry').count(), 30);
|
||||
|
||||
// Older WebKit makes an inline-size query container a fixed-position containing block.
|
||||
// Explicit layout containment reproduces that behavior on current browser engines.
|
||||
await page.addStyleTag({ content: '.content { contain: layout; min-height: 1400px; }' });
|
||||
|
||||
const assertViewportOverlay = async (selector) => {
|
||||
const bounds = await page.locator(selector).evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
});
|
||||
const viewport = page.viewportSize();
|
||||
assert.deepEqual(bounds, { x: 0, y: 0, ...viewport }, `${selector} must cover the viewport independently of page containment`);
|
||||
};
|
||||
const assertDialogControls = async (dialog) => {
|
||||
const bounds = await dialog.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= viewport.width + 1
|
||||
&& bounds.y + bounds.height <= viewport.height + 1, 'Dialog must fit inside the window');
|
||||
for (const button of ['Close', 'Cancel', 'Save']) {
|
||||
const control = dialog.getByRole('button', { name: button, exact: true });
|
||||
const visible = await control.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.bottom <= innerHeight
|
||||
&& element.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
|
||||
});
|
||||
assert.equal(visible, true, `${button} must remain visible and unobstructed`);
|
||||
}
|
||||
const dimensions = await dialog.evaluate((element) => ({ client: element.clientWidth, scroll: element.scrollWidth }));
|
||||
assert.ok(dimensions.scroll <= dimensions.client + 1, 'Dialog must not overflow horizontally');
|
||||
};
|
||||
|
||||
for (const viewport of [{ width: 1280, height: 800 }, { width: 1024, height: 600 }, { width: 640, height: 600 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await assertViewportOverlay('.config-dialog-backdrop');
|
||||
await assertDialogControls(form);
|
||||
await form.getByLabel('Remark', { exact: true }).fill('Small-window edit');
|
||||
await form.locator('.provider-advanced-settings summary').click();
|
||||
const headers = form.getByLabel(/Custom Headers/);
|
||||
await headers.fill('X-Layout-Test: reachable');
|
||||
await assertDialogControls(form);
|
||||
const scroll = await form.evaluate((element) => ({ top: element.scrollTop, max: element.scrollHeight - element.clientHeight }));
|
||||
assert.ok(scroll.top > 0 && scroll.max > 0, 'Advanced settings must be reachable by scrolling inside the dialog');
|
||||
await form.locator('.provider-advanced-settings summary').click();
|
||||
}
|
||||
|
||||
await form.getByRole('button', { name: 'Fetch Models', exact: true }).click();
|
||||
const models = page.getByRole('dialog', { name: 'Select Models', exact: true });
|
||||
await models.waitFor();
|
||||
await assertViewportOverlay('.model-discovery-backdrop');
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await models.evaluate((element) => element.contains(document.activeElement)), true);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await models.waitFor({ state: 'detached' });
|
||||
assert.equal(await form.getByRole('button', { name: 'Fetch Models', exact: true }).evaluate((element) => element === document.activeElement), true);
|
||||
await assertDialogControls(form);
|
||||
await form.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await form.waitFor({ state: 'detached' });
|
||||
const record = await page.evaluate(() => window.providerFixture.records[0]);
|
||||
assert.equal(record.headers['X-Layout-Test'], 'reachable');
|
||||
assert.equal(record.models.length, 30);
|
||||
assert.equal(await page.evaluate(() => document.body.style.overflow), '');
|
||||
|
||||
await page.locator('.provider-category-panel button').filter({ hasText: 'OpenAI' }).click();
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
const add = page.getByRole('dialog', { name: 'Add API Connection', exact: true });
|
||||
await assertViewportOverlay('.config-dialog-backdrop');
|
||||
await assertDialogControls(add);
|
||||
await add.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await add.waitFor({ state: 'detached' });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: #321 provider dialogs fit small windows, escape page containment, scroll long forms, preserve nested focus, and save edits.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
+10
-7
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { ApiAccessPage, providerRemarkIdentity, type ApiAccessRemarkLocator, type ProviderSection } from '../../src/pages/ApiAccessPage';
|
||||
import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../../src/services/managementApi';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', 'en');
|
||||
@@ -35,22 +36,24 @@ mockIPC((cmd, rawArgs) => {
|
||||
if (cmd === 'management_request') {
|
||||
const { request } = rawArgs as { request: { method: string; path: string; body: Record<string, unknown>[] } };
|
||||
const section = request.path.slice(1);
|
||||
if (request.method === 'POST' && section === 'requests/api-call') {
|
||||
return { status_code: 200, body: { data: [{ id: 'discovered-model' }] } };
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
if (section === 'config') return { 'claude-api-key': structuredClone(fixture.providerFixture.records) };
|
||||
return { [section]: section === 'claude-api-key'
|
||||
? fixture.providerFixture.records.map((record, index) => ({ ...record, 'auth-index': `auth-${index}` }))
|
||||
: [] };
|
||||
const groups = groupLegacyProviderRecords('claude', fixture.providerFixture.records);
|
||||
if (section === 'config') return { 'api-keys': { claude: groups } };
|
||||
return section === 'config/api-keys/claude' ? groups : [];
|
||||
}
|
||||
fixture.providerFixture.writes.push({ method: request.method, body: structuredClone(request.body) });
|
||||
if (request.method !== 'PUT' || section !== 'claude-api-key') throw new Error('Unexpected provider mutation');
|
||||
fixture.providerFixture.records = structuredClone(request.body);
|
||||
if (request.method !== 'PUT' || section !== 'config/api-keys/claude') throw new Error('Unexpected provider mutation');
|
||||
fixture.providerFixture.records = flattenV8ProviderGroups('claude', structuredClone(request.body));
|
||||
return { status: 'ok' };
|
||||
}
|
||||
throw new Error(`Unhandled fixture command: ${cmd}`);
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider><div className="app-shell"><div className="workspace"><main className="content">
|
||||
<I18nProvider><div className="app-shell"><aside className="sidebar" /><div className="workspace"><main className="content">
|
||||
<ApiAccessPage />
|
||||
</main></div></div></I18nProvider>,
|
||||
);
|
||||
@@ -185,7 +185,7 @@ const { mkdirSync } = require('node:fs');
|
||||
for (const [locale, theme, width, height] of [['zh-CN', 'light', 1280, 820], ['en', 'dark', 1280, 820], ['ja', 'light', 390, 740]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?locale=${locale}&theme=${theme}`);
|
||||
await page.locator('.model-config-heading button').press('Enter');
|
||||
await page.locator('.model-config-heading button').click();
|
||||
await ready();
|
||||
const popup = page.locator('.model-transfer-dialog');
|
||||
const rect = await popup.boundingBox();
|
||||
|
||||
Reference in new issue
Block a user