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
2 changes: 1 addition & 1 deletion .github/workflows/tag-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:

deploy:
needs: validate
if: startsWith(github.ref, 'refs/tags/v')
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2
with:
app: fastwrite
Expand Down
5 changes: 4 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"e2e:visual": "node ../../scripts/e2e-visual-baseline.mjs",
"e2e:components": "node ../../scripts/e2e-component-gallery.mjs"
"e2e:components": "node ../../scripts/e2e-component-gallery.mjs",
"test:fastui:browser": "node scripts/fastui-browser.mjs"
},
"dependencies": {
"@fastrd/ui-foundation": "0.1.2",
"@fastrd/ui-react": "0.1.2",
"@fastwrite/shared": "workspace:*",
"@fontsource-variable/geist": "5.3.0",
"@fontsource-variable/ibm-plex-sans": "5.3.0",
Expand Down
145 changes: 145 additions & 0 deletions apps/web/scripts/fastui-browser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { chromium, expect } from '@playwright/test';
import { preview } from 'vite';
import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';

// Production route and components; the service is substituted at its HTTP boundary.
const artifacts = 'test-results/fastui';
await mkdir(artifacts, { recursive: true });
const server = await preview({ configFile: false, preview: { host: '127.0.0.1', port: 0 } });
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block', colorScheme: 'light' });
let project = { id: 'ui-project', name: 'FastUI project', mainDocument: 'main.tex', skill: { id: 'network-information-security', name: '网络与信息安全', venue: 'network-information-security', version: '2.0.0' }, source: { type: 'local', displayName: 'Workspace' }, version: 1, createdAt: '2026-11-10T00:00:00Z', updatedAt: '2026-11-10T00:00:00Z' };
const file = { name: 'main.tex', path: 'main.tex', kind: 'text', size: 64, version: 1, updatedAt: project.updatedAt };
const writes = [], errors = [], scenarios = [];
await context.addInitScript(() => {
localStorage.setItem('fastwrite.completion.enabled', 'false');
localStorage.setItem('fastwrite.collaboration.enabled', 'false');
});
await context.route('**/api/**', async route => {
const request = route.request(), path = new URL(request.url()).pathname;
let value = [], status = 200;
if (request.method() !== 'GET') writes.push({ path, method: request.method(), body: request.postDataJSON() });
if (path === '/api/projects/ui-project') {
if (request.method() === 'PATCH') { project = { ...project, ...request.postDataJSON(), version: project.version + 1 }; }
value = project;
} else if (path === '/api/projects/ui-project/files') value = [{ ...file, type: 'file' }];
else if (path === '/api/projects/ui-project/file') value = { file, content: '\\documentclass{article}\n\\begin{document}\nDraft\n\\end{document}\n' };
else if (path === '/api/projects/ui-project/access') value = { role: 'owner', userId: 'writer', canWrite: true, canComment: true, canInvite: true, canManage: true, realtime: false, canCompile: true };
else if (path === '/api/projects/ui-project/evidence-cockpit') value = { counts: { total: 0, supported: 0, unresolved: 0 }, claims: [] };
else if (path.includes('/compile-results/')) value = null;
else if (path === '/api/harness-settings') {
assert.equal(request.method(), 'GET', 'saving general settings must not overwrite model credentials');
value = { configured: false, source: 'none', wireAPI: 'chat' };
} else if (path === '/api/auth/me') value = { id: 'writer', displayName: 'Writer', platformRole: 'user' };
else if (path.endsWith('/history/working-status')) value = { staged: [], unstaged: [], untracked: [], branch: 'main' };
else if (path.endsWith('/reference-selections')) value = { revision: 1, selections: [] };
else if (path === '/api/harnesses') value = [];
await route.fulfill({ status, json: value });
});
try {
for (const [mode, system] of [['dark', 'light'], ['light', 'dark']]) {
const cold = await context.newPage();
await cold.emulateMedia({ colorScheme: system });
await cold.addInitScript(mode => {
localStorage.setItem('fastwrite.theme', mode);
const observer = new MutationObserver(() => {
if (!document.getElementById('root')?.childElementCount) return;
window.firstRenderedTheme = { host: document.documentElement.dataset.theme, foundation: document.documentElement.dataset.fastuiTheme };
observer.disconnect();
});
observer.observe(document, { childList: true, subtree: true });
}, mode);
await cold.goto(`${base}/projects/ui-project`);
await expect(cold.getByRole('button', { name: '工作区设置', exact: true })).toBeVisible();
assert.deepEqual(await cold.evaluate(() => window.firstRenderedTheme), { host: mode, foundation: mode }, 'stored theme must apply before the first React render');
assert.equal(await cold.evaluate(() => localStorage.getItem('fastwrite.theme')), mode);
await cold.evaluate(() => localStorage.removeItem('fastwrite.theme'));
await cold.close();
}
scenarios.push('saved light/dark pin is applied before the first React render, independently of the OS');
const page = await context.newPage();
page.on('pageerror', error => { errors.push(error.message); console.error(error.stack); });
await page.goto(`${base}/projects/ui-project`);
await page.getByRole('button', { name: '工作区设置', exact: true }).click();
const panel = page.getByRole('tabpanel', { name: '设置', exact: true });
const name = panel.getByLabel('项目名称', { exact: true });
await expect(name).toHaveClass(/fastui-input/);
await expect(name).toHaveCSS('font-family', /IBM Plex Sans Variable/);
await expect(name).toHaveCSS('height', '28px');
await expect(name).toHaveCSS('border-radius', '4px');
await expect(name).toHaveCSS('border-color', 'rgb(130, 144, 163)');
await name.fill(' ');
await expect(panel.getByRole('button', { name: '保存更改', exact: true })).toBeDisabled();
await name.fill('设置草稿保持');
await name.focus();
const original = await name.elementHandle();
const peer = await context.newPage();
await peer.goto(base);
await peer.evaluate(() => localStorage.setItem('fastwrite.theme', 'dark'));
await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(name).toBeFocused();
await expect(name).toHaveValue('设置草稿保持');
assert.equal(await original.evaluate(el => el.isConnected), true);
await expect(name).toHaveCSS('background-color', 'rgb(23, 30, 39)');
scenarios.push('real settings tab consumes ui-react; cross-tab theme preserves input node, focus and draft');
await peer.evaluate(() => localStorage.setItem('fastwrite.theme', 'system'));
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'dark');
await page.emulateMedia({ colorScheme: 'light' });
await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', 'light');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await expect(name).toBeFocused();
await expect(name).toHaveValue('设置草稿保持');
assert.equal(await original.evaluate(el => el.isConnected), true);
scenarios.push('system preference changes retain focused settings input and draft');
const navigation = panel.getByRole('navigation', { name: '设置类别' });
await navigation.getByRole('button', { name: '编译环境', exact: true }).click();
await expect(panel.getByRole('combobox', { name: 'TeX 引擎' })).toBeVisible();
await navigation.getByRole('button', { name: '外部 MCP', exact: true }).click();
await expect(panel.getByRole('button', { name: '创建 30 天连接令牌' })).toBeVisible();
scenarios.push('main typography and compact geometry apply to shared inputs; compilation and MCP settings stay reachable');
await navigation.getByRole('button', { name: 'AI 与模型配置', exact: true }).click();
const key = panel.getByLabel('API 密钥', { exact: true });
await key.fill('model-key-draft');
await key.press('Tab');
await expect(panel.getByLabel('服务地址', { exact: true })).toBeFocused();
await panel.getByRole('searchbox', { name: '搜索设置' }).fill('名称');
await expect(name).toHaveValue('设置草稿保持');
await navigation.getByRole('button', { name: '常规', exact: true }).click();
// Leave the model draft empty to exercise the existing unchanged-model save decision.
await navigation.getByRole('button', { name: 'AI 与模型配置', exact: true }).click();
await key.fill('');
await navigation.getByRole('button', { name: '常规', exact: true }).click();
await panel.getByRole('button', { name: '保存更改', exact: true }).click();
await expect(panel.getByRole('status')).toHaveText('设置已保存');
const saved = writes.find(write => write.path === '/api/projects/ui-project' && write.method === 'PATCH');
assert.equal(saved.body.name, '设置草稿保持');
assert.equal(writes.some(write => write.path === '/api/harness-settings'), false);
scenarios.push('label wiring, keyboard Tab, category/search retain drafts, controlled save retains model settings');
// Use the existing workbench pane controls before checking the compact settings surface.
await page.getByRole('button', { name: '隐藏面板', exact: true }).click();
await page.getByRole('button', { name: '收起 PDF 预览', exact: true }).click();
await page.getByRole('button', { name: '收起文件面板', exact: true }).click();
for (const width of [1440, 1024, 390]) for (const mode of ['light', 'dark']) {
await page.setViewportSize({ width, height: 1000 });
await peer.evaluate(mode => localStorage.setItem('fastwrite.theme', mode), mode);
await expect(page.locator('html')).toHaveAttribute('data-fastui-theme', mode);
const primary = panel.getByRole('button', { name: '保存更改', exact: true });
await expect(primary).toHaveCSS('background-color', mode === 'dark' ? 'rgb(14, 99, 156)' : 'rgb(21, 95, 184)');
await expect(primary).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(name).toHaveCSS('font-family', /IBM Plex Sans Variable/);
await expect(name).toHaveCSS('background-color', mode === 'dark' ? 'rgb(23, 30, 39)' : 'rgb(255, 255, 255)');
await expect(name).toHaveValue('设置草稿保持');
assert.ok(await panel.evaluate(el => [...el.querySelectorAll('input')].filter(input => input.getClientRects().length).every(input => input.getBoundingClientRect().right <= el.getBoundingClientRect().right + 1)));
await page.screenshot({ path: `${artifacts}/${mode}-${width}.png` });
}
assert.deepEqual(errors, []);
await writeFile(`${artifacts}/results.json`, JSON.stringify({ status: 'passed', scenarios, viewports: [1440, 1024, 390] }, null, 2));
console.log('PASS FastWrite FastUI production settings browser scenarios');
} finally {
await browser.close();
await new Promise(resolve => server.httpServer.close(resolve));
}
2 changes: 0 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { FASTWRITE_SAVE_EVENT, isSaveShortcut } from "./lib/keyboard";
import { applyTheme, initialTheme } from "./lib/theme";
import { api } from "./api/client";
import { WorkbenchNotifications } from "./components/workbench/WorkbenchNotifications";

Expand Down Expand Up @@ -32,7 +31,6 @@ export function App() {
const parameters = new URLSearchParams(window.location.search);
return import.meta.env.VITE_FASTWRITE_DEVELOPMENT_ADMIN !== "true" && !["oidc", "cas", "fastcas"].some((provider) => parameters.get(provider) === "complete");
});
useEffect(() => { applyTheme(initialTheme()); }, []);
useEffect(() => {
const parameters = new URLSearchParams(window.location.search);
if (import.meta.env.VITE_FASTWRITE_DEVELOPMENT_ADMIN === "true") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
.title { display: flex; align-items: baseline; gap: 14px; min-width: 0; margin-bottom: 16px; }
.title h1 { margin: 0; font-size: 22px; font-weight: 400; }
.title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); font-size: 12px; }
.header > input { width: 100%; height: 30px; }
.editor :global(.fastui-input) { min-height: 0; height: var(--fw-control-height); padding: 4px var(--fw-control-padding-inline); font-size: var(--vscode-font-size); font-weight: var(--fw-font-weight-body); line-height: 18px; }
.header > :global(.fastui-input) { width: 100%; height: 30px; }
.scope { display: flex; align-items: center; gap: 14px; margin-top: 12px; border-bottom: 1px solid var(--border); }
.scope > span { padding: 9px 0; border-bottom: 2px solid var(--accent); font-size: 12px; }
.scope small { color: var(--text-muted); font-size: 11px; }
Expand All @@ -27,6 +28,7 @@
.section h2, .empty h2 { margin: 0; font-size: 18px; font-weight: 500; }
.description { margin: -10px 0 0; color: var(--text-muted); font-size: 12px; line-height: 1.6; }
.section > div { min-width: 0; }
.section :global(.fastui-input) { width: 100%; }
.section :global(.settings-agent), .section:global(.settings-agent), .section :global(.settings-export) { padding: 0; border: 0; border-radius: 0; background: transparent; }
.section :global(.settings-agent__actions) { flex-wrap: wrap; }
.section :global(.settings-agent__actions) small { flex: 1 1 180px; }
Expand Down
Loading
Loading