再次合并 master 到 AGC 交付效率分支
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- 合并 master 的项目列表状态隔离与客户端渠道、错误报告展示修复
- 决策日志保留本次 DirectProject 交付收敛条目与 master 的最近项目隔离条目
- 修正上一轮合并中被 lint-staged 回滚的 codex_provider_proxy 结构(重复 impl 头与重复 use),该文件现在与验证过的源码一致
This commit is contained in:
kdletters
2026-09-21 02:02:11 +08:00
24 changed files with 1147 additions and 129 deletions
+2
View File
@@ -144,6 +144,8 @@ export interface AdminErrorReportListResponse {
}
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
firstFingerprint?: string;
firstSource?: string;
note?: string;
events: Array<Record<string, unknown>>;
logNames: string[];
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import {
formatAdminTimestamp,
parseAdminTimestamp,
} from './AdminErrorReportsPage';
describe('错误报告时间格式化', () => {
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
});
it('解析微秒字符串并拒绝无效时间', () => {
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
expect(formatAdminTimestamp('not-a-date')).toBe('-');
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
});
});
@@ -17,6 +17,57 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
const ERROR_REPORT_PAGE_SIZE = 50;
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
new: '待处理',
'in-progress': '处理中',
resolved: '已解决',
};
export function parseAdminTimestamp(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
const seconds = Number(secondsText);
const micros = Number(microsText);
return Number.isFinite(seconds) && Number.isFinite(micros)
? seconds * 1000 + Math.floor(micros / 1000)
: Number.NaN;
}
if (/^-?\d+$/u.test(normalized)) {
const numeric = Number(normalized);
if (!Number.isFinite(numeric)) return Number.NaN;
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
if (Math.abs(numeric) >= 1e11) return numeric;
return numeric * 1000;
}
return Date.parse(normalized);
}
export function formatAdminTimestamp(value: string | null | undefined) {
const timestamp = parseAdminTimestamp(value);
if (!Number.isFinite(timestamp)) return '-';
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return '-';
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
} catch {
return '-';
}
}
function eventText(event: Record<string, unknown>, key: string) {
const value = event[key];
return typeof value === 'string' || typeof value === 'number'
? String(value)
: '';
}
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
@@ -29,6 +80,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
const openReportRequestId = useRef(0);
const loadRequestId = useRef(0);
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!selected) return;
const previousFocus = document.activeElement as HTMLElement | null;
const focusFrame = window.requestAnimationFrame(() => {
detailCloseButtonRef.current?.focus();
});
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelected(null);
};
window.addEventListener('keydown', handleEscape);
return () => {
window.cancelAnimationFrame(focusFrame);
window.removeEventListener('keydown', handleEscape);
previousFocus?.focus?.();
};
}, [selected]);
const load = useCallback(async () => {
const requestId = ++loadRequestId.current;
@@ -184,9 +253,15 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
<td>{report.batchId}</td>
<td>{report.eventCount}</td>
<td>{report.source ?? '-'}</td>
<td>{report.status}</td>
<td>
<span
className={`admin-error-report-status is-${report.status}`}
>
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
</span>
</td>
<td>{report.userId}</td>
<td>{new Date(report.createdAt).toLocaleString()}</td>
<td>{formatAdminTimestamp(report.createdAt)}</td>
</tr>
))}
</tbody>
@@ -224,66 +299,173 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
className="admin-detail-modal"
role="dialog"
aria-label="错误报告详情"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setSelected(null);
}}
>
<div className="admin-detail-modal__panel">
<header>
<h2>{selected.batchId}</h2>
<button type="button" onClick={() => setSelected(null)}>
关闭
<div className="admin-detail-modal__panel admin-error-report-detail">
<header className="admin-error-report-detail__header">
<div className="admin-error-report-detail__heading">
<span className="admin-error-report-detail__eyebrow">
错误报告详情
</span>
<h2>{selected.batchId}</h2>
<div className="admin-error-report-detail__badges">
<span
className={`admin-error-report-status is-${selected.status}`}
>
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
selected.status}
</span>
<span className="admin-error-report-detail__badge">
{selected.firstSource ?? selected.source ?? '未知来源'}
</span>
</div>
</div>
<button
type="button"
className="admin-error-report-detail__close"
ref={detailCloseButtonRef}
onClick={() => setSelected(null)}
aria-label="关闭错误报告详情"
>
×
</button>
</header>
<p>
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
{selected.logCount}
</p>
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
{selected.userDescription ? (
<p>用户描述:{selected.userDescription}</p>
) : null}
<label className="admin-detail-modal__note">
处理备注
<textarea
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current
? { ...current, note: event.target.value }
: current,
)
}
maxLength={2000}
rows={4}
placeholder="记录处理结论或后续跟进事项"
disabled={busy}
/>
</label>
<div className="admin-detail-modal__actions">
<select
value={selected.status}
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
type="button"
onClick={() => void saveStatus(selected.status)}
disabled={busy}
>
保存备注
</button>
<button
type="button"
onClick={() => void download(selected.batchId)}
disabled={downloading}
>
{downloading ? '下载中…' : '下载诊断包'}
</button>
<div className="admin-error-report-detail__summary">
<div>
<span>用户</span>
<strong>{selected.userId}</strong>
</div>
<div>
<span>发生时间</span>
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
</div>
<div>
<span>错误事件</span>
<strong>{selected.eventCount}</strong>
</div>
<div>
<span>应用日志</span>
<strong>{selected.logCount}</strong>
</div>
</div>
<div className="admin-error-report-detail__body">
<section className="admin-error-report-detail__section">
<div className="admin-error-report-detail__section-heading">
<div>
<h3>错误事件</h3>
<span>展示最近 20 条,完整内容请下载诊断包。</span>
</div>
<span className="admin-error-report-detail__count">
{selected.events.length} 条
</span>
</div>
<div className="admin-error-report-event-list">
{selected.events.length === 0 ? (
<p className="admin-error-report-detail__empty">
归档中没有可展示的错误事件。
</p>
) : null}
{selected.events.slice(0, 20).map((event, index) => {
const message = eventText(event, 'message') || '未知错误';
const stack = eventText(event, 'stack');
const occurredAt = eventText(event, 'occurredAt');
return (
<article
className="admin-error-report-event"
key={`${index}-${message}`}
>
<div className="admin-error-report-event__topline">
<span className="admin-error-report-event__index">
#{index + 1}
</span>
<span>{eventText(event, 'source') || 'client'}</span>
<span>{eventText(event, 'count') || '1'} 次</span>
<time>{formatAdminTimestamp(occurredAt)}</time>
</div>
<p className="admin-error-report-event__message">
{message}
</p>
{eventText(event, 'fingerprint') ? (
<code>{eventText(event, 'fingerprint')}</code>
) : null}
{stack ? (
<details>
<summary>查看调用栈</summary>
<pre>{stack}</pre>
</details>
) : null}
</article>
);
})}
</div>
</section>
{selected.userDescription ? (
<section className="admin-error-report-detail__section">
<div className="admin-error-report-detail__section-heading">
<div>
<h3>用户描述</h3>
</div>
</div>
<p className="admin-error-report-detail__description">
{selected.userDescription}
</p>
</section>
) : null}
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
<label className="admin-detail-modal__note">
<span>处理备注</span>
<textarea
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current
? { ...current, note: event.target.value }
: current,
)
}
maxLength={2000}
rows={4}
placeholder="记录处理结论或后续跟进事项"
disabled={busy}
/>
</label>
</section>
</div>
<footer className="admin-error-report-detail__footer">
<label className="admin-error-report-detail__status-field">
状态
<select
value={selected.status}
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{ERROR_REPORT_STATUS_LABELS[value]}
</option>
))}
</select>
</label>
<div className="admin-error-report-detail__footer-actions">
<button
type="button"
onClick={() => void saveStatus(selected.status)}
disabled={busy}
>
{busy ? '保存中…' : '保存备注'}
</button>
<button
type="button"
onClick={() => void download(selected.batchId)}
disabled={downloading}
>
{downloading ? '下载中…' : '下载诊断包'}
</button>
</div>
</footer>
</div>
</div>
) : null}
+371
View File
@@ -3213,6 +3213,377 @@ button:disabled {
padding: 12px;
border-radius: 8px;
}
.admin-error-report-detail {
width: min(980px, 100%);
padding: 0;
overflow: hidden;
border: 1px solid #eadfd6;
background: #fffdfb;
box-shadow: 0 24px 80px rgb(57 31 18 / 22%);
}
.admin-error-report-detail__header {
align-items: flex-start;
padding: 24px 28px 20px;
border-bottom: 1px solid #eee2d8;
background: linear-gradient(135deg, #fffaf5, #fffdfb 65%);
}
.admin-error-report-detail__heading {
min-width: 0;
}
.admin-error-report-detail__eyebrow {
display: block;
margin-bottom: 7px;
color: #a4775d;
font-size: 12px;
font-weight: 750;
letter-spacing: 0.08em;
}
.admin-error-report-detail__heading h2 {
margin: 0;
overflow-wrap: anywhere;
color: #3d2a20;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 19px;
line-height: 1.35;
}
.admin-error-report-detail__badges,
.admin-error-report-detail__footer-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.admin-error-report-detail__badges {
margin-top: 12px;
}
.admin-error-report-detail__badge,
.admin-error-report-detail__count {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border: 1px solid #eadfd6;
border-radius: 999px;
color: #785b49;
background: #fff;
font-size: 12px;
font-weight: 700;
}
.admin-error-report-detail__close {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border: 1px solid #e5d5c8;
border-radius: 9px;
color: #765848;
background: #fff;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.admin-error-report-detail__close:hover,
.admin-error-report-detail__close:focus-visible {
border-color: #b6623f;
color: #9b4f31;
outline: none;
}
.admin-error-report-status {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 750;
}
.admin-error-report-status.is-new {
color: #a14e20;
background: #fff0e5;
}
.admin-error-report-status.is-in-progress {
color: #72551b;
background: #fff7d9;
}
.admin-error-report-status.is-resolved {
color: #28704d;
background: #e9f7ee;
}
.admin-error-report-detail__summary {
display: grid;
grid-template-columns: minmax(0, 1.7fr) repeat(3, minmax(100px, 1fr));
gap: 1px;
border-bottom: 1px solid #eee2d8;
background: #eee2d8;
}
.admin-error-report-detail__summary > div {
display: grid;
min-width: 0;
gap: 5px;
padding: 14px 18px;
background: #fffdfb;
}
.admin-error-report-detail__summary span,
.admin-error-report-detail__section-heading span,
.admin-error-report-detail__status-field {
color: #997d6a;
font-size: 12px;
font-weight: 650;
}
.admin-error-report-detail__summary strong {
overflow-wrap: anywhere;
color: #4d3326;
font-size: 13px;
}
.admin-error-report-detail__body {
display: grid;
gap: 16px;
max-height: min(62vh, 640px);
overflow: auto;
padding: 20px 28px;
}
.admin-error-report-detail__section {
display: grid;
gap: 12px;
}
.admin-error-report-detail__section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.admin-error-report-detail__section-heading h3 {
margin: 0 0 4px;
color: #4d3326;
font-size: 15px;
}
.admin-error-report-detail__section-heading span {
line-height: 1.5;
}
.admin-error-report-detail__count {
flex: 0 0 auto;
}
.admin-error-report-event-list {
display: grid;
gap: 10px;
}
.admin-error-report-detail__empty {
margin: 0;
padding: 16px;
border: 1px dashed #e4d2c5;
border-radius: 10px;
color: #997d6a;
background: #fffaf6;
text-align: center;
}
.admin-error-report-event {
display: grid;
gap: 9px;
padding: 14px 16px;
border: 1px solid #eee2d8;
border-radius: 12px;
background: #fff;
}
.admin-error-report-event__topline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: #927663;
font-size: 12px;
}
.admin-error-report-event__topline time {
margin-left: auto;
color: #aa9282;
}
.admin-error-report-event__index {
color: #b6623f;
font-weight: 750;
}
.admin-error-report-event__message {
margin: 0;
color: #4a3024;
font-size: 14px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.admin-error-report-event code {
overflow-wrap: anywhere;
color: #957a69;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 11px;
}
.admin-error-report-event details {
border-top: 1px solid #f0e7df;
padding-top: 9px;
}
.admin-error-report-event summary {
color: #a15d3e;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.admin-error-report-event pre {
max-height: 180px;
margin: 9px 0 0;
padding: 10px;
border: 1px solid #f0e7df;
background: #fffaf6;
color: #72594a;
font-size: 11px;
line-height: 1.5;
}
.admin-error-report-detail__description {
margin: 0;
padding: 13px 15px;
border-radius: 10px;
color: #634b3c;
background: #fff8f2;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.admin-error-report-detail__section--note {
padding-top: 4px;
}
.admin-error-report-detail__section--note .admin-detail-modal__note {
display: grid;
gap: 8px;
color: #624938;
font-size: 13px;
font-weight: 700;
}
.admin-error-report-detail__section--note textarea {
width: 100%;
min-height: 100px;
box-sizing: border-box;
resize: vertical;
border: 1px solid #e4d2c5;
border-radius: 10px;
padding: 11px 12px;
color: #4d3326;
background: #fff;
font: inherit;
line-height: 1.5;
}
.admin-error-report-detail__section--note textarea:focus {
border-color: #b6623f;
box-shadow: 0 0 0 3px rgb(182 98 63 / 14%);
outline: none;
}
.admin-error-report-detail__footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 16px 28px 20px;
border-top: 1px solid #eee2d8;
background: #fffaf6;
}
.admin-error-report-detail__status-field {
display: grid;
gap: 6px;
}
.admin-error-report-detail__status-field select {
min-width: 132px;
min-height: 38px;
border: 1px solid #e4d2c5;
border-radius: 8px;
padding: 0 10px;
color: #4d3326;
background: #fff;
font: inherit;
}
.admin-error-report-detail__footer-actions button {
min-height: 38px;
border: 1px solid #d9c3b4;
border-radius: 8px;
padding: 0 13px;
color: #6d4b3a;
background: #fff;
font-weight: 700;
cursor: pointer;
}
.admin-error-report-detail__footer-actions button:last-child {
border-color: #a96442;
color: #fff;
background: #a96442;
}
.admin-error-report-detail__footer-actions button:hover,
.admin-error-report-detail__footer-actions button:focus-visible {
border-color: #a96442;
outline: none;
}
@media (max-width: 720px) {
.admin-error-report-detail__header,
.admin-error-report-detail__body,
.admin-error-report-detail__footer {
padding-right: 18px;
padding-left: 18px;
}
.admin-error-report-detail__summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-error-report-detail__footer {
align-items: stretch;
flex-direction: column;
}
.admin-error-report-detail__footer-actions button {
flex: 1;
}
}
.admin-agc-models {
min-width: 0;
}
@@ -490,7 +490,17 @@ export function runTauriBuild(
const result = spawn(
npmCommand,
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
{
cwd: appRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
env: {
...process.env,
// Vite embeds the platform API origin in the packaged renderer. The
// release channel and updater channel therefore cannot drift apart.
VITE_AGC_PLATFORM_CHANNEL: channel,
},
},
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
@@ -204,6 +204,21 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
});
});
test('packaged renderer receives the same channel as the updater manifest', () => {
const context = resolveReleaseContext([], {
AGC_BUILD_TARGET: windowsTarget,
AGC_UPDATE_CHANNEL: 'release',
});
let spawnOptions;
runTauriBuild([], context, {
spawn: (_binary, _args, options) => {
spawnOptions = options;
return { status: 0 };
},
});
assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release');
});
test('macOS manifests advertise exactly the architectures actually built', () => {
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
'darwin-aarch64',
@@ -5,7 +5,6 @@ use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode};
use axum::routing::any;
use axum::Router;
use futures::{Stream, StreamExt};
use super::{DirectMetricAttempt, DirectMetricRoute, DirectRequestTiming};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
@@ -63,7 +62,6 @@ impl Drop for CodexProviderModelUsageGuard {
}
}
impl CodexProviderProxy {
impl CodexProviderProxy {
pub(crate) fn base_url(&self) -> &str {
&self.base_url
@@ -13,7 +13,8 @@ static TOKEN_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
.expect("valid diagnostic sanitization pattern")
});
static URL_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)https?://\S+").expect("valid diagnostic sanitization pattern")
regex::Regex::new(r"(?i)https?://[^\s/?#]+(?P<path>/[^\s?#]*)?(?:[?#][^\s]*)?")
.expect("valid diagnostic sanitization pattern")
});
static PATH_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)[A-Z]:[\\/][^\s]+|/(?:Users|home|private|tmp)/[^\s]+")
@@ -27,13 +28,30 @@ fn replace_pattern(value: String, pattern: &regex::Regex, replacement: &str) ->
pattern.replace_all(&value, replacement).into_owned()
}
fn redact_urls(value: &str) -> String {
URL_PATTERN
.replace_all(value, |captures: &regex::Captures<'_>| {
let path = captures
.name("path")
.map(|value| value.as_str())
.unwrap_or("/");
if path.starts_with("/api/") || path.starts_with("/v1/") || path.starts_with("/admin/")
{
format!("<origin>{path}")
} else {
"<url>".to_string()
}
})
.into_owned()
}
pub(crate) fn sanitize(value: &str, max_chars: usize) -> String {
let normalized = value
.to_string()
.pipe(|value| replace_pattern(value, &AUTHORIZATION_PATTERN, "authorization: [REDACTED]"))
.pipe(|value| replace_pattern(value, &BEARER_PATTERN, "Bearer [REDACTED]"))
.pipe(|value| replace_pattern(value, &TOKEN_PATTERN, "[REDACTED]"))
.pipe(|value| replace_pattern(value, &URL_PATTERN, "<url>"))
.pipe(|value| redact_urls(&value))
.pipe(|value| replace_pattern(value, &PATH_PATTERN, "<path>"))
.pipe(|value| replace_pattern(value, &HEX_ID_PATTERN, "<id>"));
normalized
@@ -83,6 +101,17 @@ mod tests {
assert!(!sanitized.contains("/users/alice"));
}
#[test]
fn keeps_api_route_while_redacting_url_origin_and_query() {
let sanitized = sanitize(
"请求超时:https://dev.genarrative.world/api/llm/models?token=secret",
512,
);
assert_eq!(sanitized, "请求超时:<origin>/api/llm/models");
assert!(!sanitized.contains("genarrative.world"));
assert!(!sanitized.contains("secret"));
}
#[test]
fn removes_newlines_and_bounds_length() {
assert_eq!(sanitize("a\nb\u{0000}c", 3), "a b");
@@ -23,7 +23,15 @@ import {
normalizeAuthPhoneInput,
sendClientPhoneLoginCode,
} from '../services/clientAuth';
import { getClientServerBaseUrl } from '../services/clientHttp';
import {
type ClientServerPreset,
type ClientServerSelection,
clientServerSelectionEnabled,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import {
captureClientError,
installWebviewLogBridge,
@@ -151,6 +159,40 @@ export function AuthenticatedClient({
const [loginBusy, setLoginBusy] = useState(false);
const [codeBusy, setCodeBusy] = useState(false);
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
const initialServerSelection = getClientServerSelection();
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
initialServerSelection,
);
const [customServerUrl, setCustomServerUrl] = useState(
initialServerSelection.customBaseUrl,
);
function persistServerSelection() {
try {
const next = setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return true;
} catch (error) {
setLoginStatus(error instanceof Error ? error.message : String(error));
return false;
}
}
function handleServerPresetChange(preset: ClientServerPreset) {
if (preset === 'custom') {
setServerSelection((current) => ({ ...current, preset }));
return;
}
const next = setClientServerSelection({
preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
useEffect(() => {
const uninstallWebviewLogBridge = installWebviewLogBridge();
const handleError = (event: ErrorEvent) => {
@@ -385,6 +427,7 @@ export function AuthenticatedClient({
if (codeBusy || codeCooldownSeconds > 0) {
return;
}
if (!persistServerSelection()) return;
const apiBaseUrl = getClientServerBaseUrl();
const normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) {
@@ -430,6 +473,7 @@ export function AuthenticatedClient({
setLoginStatus('请输入密码');
return;
}
if (!persistServerSelection()) return;
const loginApiBaseUrl = getClientServerBaseUrl();
const loginAttempt = (loginAttemptRef.current += 1);
setLoginBusy(true);
@@ -565,6 +609,55 @@ export function AuthenticatedClient({
<h1>登录陶泥儿 GameAgent</h1>
<p>登录后进入首页和本地项目工作区</p>
</div>
{clientServerSelectionEnabled ? (
<>
<label>
服务器
<select
aria-label="服务器"
disabled={loginBusy || codeBusy}
value={serverSelection.preset}
onChange={(event) =>
handleServerPresetChange(
event.currentTarget.value as ClientServerPreset,
)
}
>
<option value="release">release</option>
<option value="dev">dev</option>
<option value="custom">custom</option>
</select>
</label>
{serverSelection.preset === 'custom' ? (
<label>
自定义服务器地址
<input
aria-label="自定义服务器地址"
disabled={loginBusy || codeBusy}
inputMode="url"
placeholder="https://example.com"
value={customServerUrl}
onChange={(event) =>
setCustomServerUrl(event.currentTarget.value)
}
onBlur={() => {
if (!customServerUrl.trim()) return;
try {
normalizeClientServerBaseUrl(customServerUrl);
persistServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error
? error.message
: String(error),
);
}
}}
/>
</label>
) : null}
</>
) : null}
{authCheckError ? (
<div role="alert" className="client-auth-status">
<p>{authCheckError}</p>
@@ -3,6 +3,7 @@ import {
type SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -31,6 +32,9 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] =
useState(false);
const [projectSearchQuery, setProjectSearchQuery] = useState('');
const recentWorkspacesRef = useRef(recentWorkspaces);
recentWorkspacesRef.current = recentWorkspaces;
const inspectionGenerationRef = useRef(0);
async function inspectRecentWorkspace(
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
@@ -62,19 +66,36 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke || recentWorkspaces.length === 0) {
inspectionGenerationRef.current += 1;
setRecentWorkspaceStatuses({});
setRecentWorkspaceRefreshing(false);
return;
}
const inspectionGeneration = ++inspectionGenerationRef.current;
let disposed = false;
let pendingCount = recentWorkspaces.length;
setRecentWorkspaceStatuses({});
// 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或
// 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表
// 清空成同一个异常状态。
setRecentWorkspaceStatuses((current) => {
const next: Record<string, LocalProjectDirectoryStatus | null> = {};
for (const workspace of recentWorkspaces) {
if (Object.prototype.hasOwnProperty.call(current, workspace)) {
next[workspace] = current[workspace] ?? null;
}
}
return next;
});
setRecentWorkspaceRefreshing(true);
for (const workspace of recentWorkspaces) {
void inspectRecentWorkspace(invoke, workspace).then(
([projectPath, status]) => {
if (disposed) {
if (
disposed ||
inspectionGeneration !== inspectionGenerationRef.current ||
!recentWorkspacesRef.current.includes(projectPath)
) {
return;
}
setRecentWorkspaceStatuses((current) => ({
@@ -104,6 +125,9 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
return;
}
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
if (!recentWorkspacesRef.current.includes(projectPath)) {
return;
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
@@ -51,7 +51,6 @@ function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput {
export function getStoredAuthAccessToken(
apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) return '';
const token =
window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
if (!token) return '';
@@ -60,9 +59,12 @@ export function getStoredAuthAccessToken(
);
if (storedOrigin === apiBaseUrl) return token;
// Old preferences were editable independently of the token, so they cannot
// establish its origin. Recover an unmarked session through the dev cookie.
// establish an unmarked session's origin. A marked token from another server
// is discarded, while the user's current debug server preference remains.
clearStoredAuthAccessToken();
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
if (!storedOrigin) {
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
}
return '';
}
@@ -70,14 +72,10 @@ export function setStoredAuthAccessToken(
token: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) {
throw new Error('登录凭据不属于客户端固定的 dev 服务');
}
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
window.localStorage.setItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY, apiBaseUrl);
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
return;
}
clearStoredAuthAccessToken();
@@ -400,7 +398,7 @@ export async function logoutClientAuthSession(
apiBaseUrl = getClientServerBaseUrl(),
) {
try {
if (!getStoredAuthAccessToken()) {
if (!getStoredAuthAccessToken(apiBaseUrl)) {
await refreshClientAuthAccessToken(apiBaseUrl).catch(() => '');
}
try {
@@ -1,8 +1,31 @@
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
export const AGC_CLIENT_MARKER_VALUE = 'agc';
export type ClientServerPreset = 'release' | 'dev' | 'custom';
export type ClientServerSelection = {
preset: ClientServerPreset;
customBaseUrl: string;
};
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
const CLIENT_PLATFORM_CHANNEL =
import.meta.env.VITE_AGC_PLATFORM_CHANNEL?.trim() === 'release'
? 'release'
: 'dev';
/** 本地 Vite debug 才允许切换平台服务器;打包产物始终跟随构建渠道。 */
export const clientServerSelectionEnabled = import.meta.env.DEV;
export function isClientServerSelectionEnabled() {
const value = String(import.meta.env.DEV);
return value === 'true' || value === '1';
}
/**
* Upper bound for the initial network transaction (DNS/connect/response
* headers). Callers may override this for a request that legitimately needs
@@ -84,8 +107,114 @@ export async function readClientHttpResponseText(
}
}
export function getClientServerBaseUrl() {
return AGC_DEVELOPMENT_API_BASE_URL;
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
if (isClientServerSelectionEnabled()) return 'dev';
return CLIENT_PLATFORM_CHANNEL;
}
function isClientServerPreset(value: unknown): value is ClientServerPreset {
return value === 'release' || value === 'dev' || value === 'custom';
}
export function normalizeClientServerBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/u, '');
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('服务器地址无效');
}
if (
!['http:', 'https:'].includes(parsed.protocol) ||
parsed.username ||
parsed.password ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash
) {
throw new Error('服务器地址必须是纯 HTTP(S) 地址');
}
const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(
parsed.hostname,
);
if (parsed.protocol === 'http:' && !isLoopback) {
throw new Error('非本机服务器必须使用 HTTPS');
}
return normalized;
}
function readStoredClientServerSelection(): ClientServerSelection {
const fallback: ClientServerSelection = {
preset: defaultClientServerPreset(),
customBaseUrl: '',
};
if (!isClientServerSelectionEnabled() || typeof window === 'undefined') {
return fallback;
}
try {
const raw = window.localStorage.getItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as {
preset?: unknown;
customBaseUrl?: unknown;
};
if (!isClientServerPreset(parsed.preset)) return fallback;
const customBaseUrl =
typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : '';
if (parsed.preset === 'custom') {
normalizeClientServerBaseUrl(customBaseUrl);
}
return { preset: parsed.preset, customBaseUrl };
} catch {
return fallback;
}
}
export function getClientServerSelection() {
return readStoredClientServerSelection();
}
export function setClientServerSelection(
selection: ClientServerSelection,
): ClientServerSelection {
const next: ClientServerSelection = {
preset: selection.preset,
customBaseUrl:
selection.preset === 'custom'
? normalizeClientServerBaseUrl(selection.customBaseUrl)
: selection.customBaseUrl.trim(),
};
if (typeof window !== 'undefined') {
window.localStorage.setItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
JSON.stringify(next),
);
}
return next;
}
export function resetClientServerSelectionForTests() {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY);
}
}
function getChannelServerBaseUrl() {
return CLIENT_PLATFORM_CHANNEL === 'release'
? AGC_RELEASE_API_BASE_URL
: AGC_DEVELOPMENT_API_BASE_URL;
}
export function getClientServerBaseUrl(selection?: ClientServerSelection) {
const resolved =
selection ??
(isClientServerSelectionEnabled() ? getClientServerSelection() : null);
if (!resolved) return getChannelServerBaseUrl();
if (resolved.preset === 'release') return AGC_RELEASE_API_BASE_URL;
if (resolved.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
return normalizeClientServerBaseUrl(resolved.customBaseUrl);
}
type ClientHttpContext = {
@@ -116,7 +245,17 @@ export function resolveClientHttpTarget(
url: string,
context: ClientHttpContext = currentClientHttpContext(),
): ClientHttpTarget {
const serverBaseUrl = getClientServerBaseUrl();
// Unit fixtures use relative requests after the same origin validation.
if (context.mode === 'test' && !context.serverBaseUrl) {
return { transport: 'web', url };
}
const serverBaseUrl = context.serverBaseUrl ?? getClientServerBaseUrl();
if (
!isClientServerSelectionEnabled() &&
serverBaseUrl !== getChannelServerBaseUrl()
) {
throw new Error('请求目标不在当前构建渠道的服务器范围内');
}
const target = new URL(url, `${serverBaseUrl}/`);
if (
(context.serverBaseUrl && context.serverBaseUrl !== serverBaseUrl) ||
@@ -124,10 +263,9 @@ export function resolveClientHttpTarget(
target.username ||
target.password
) {
throw new Error('请求目标不在客户端固定的 dev 服务范围内');
throw new Error('请求目标不在当前构建渠道的服务器范围内');
}
// Unit fixtures use relative requests after the same origin validation.
if (context.mode === 'test') {
return { transport: 'web', url };
}
@@ -61,15 +61,34 @@ export async function invokeDiagnostic<T>(
* 交互层的失败提示要保留原因时就复用它,别在业务文件里另写一套正则 ——
* 脱敏口径必须只有一份,否则"某条路径漏了"会随调用点漂移。
*/
function redactDiagnosticUrl(value: string) {
return value.replace(/https?:\/\/[^\s"'<>]+/giu, (rawUrl) => {
let parsed: URL;
try {
parsed = new URL(rawUrl.replace(/[),.;!?]+$/u, ''));
} catch {
return '<url>';
}
const pathname = parsed.pathname || '/';
if (
pathname.startsWith('/api/') ||
pathname.startsWith('/v1/') ||
pathname.startsWith('/admin/')
) {
return `<origin>${pathname}`;
}
return '<url>';
});
}
export function normalizeDiagnosticText(value: string) {
return value
return redactDiagnosticUrl(value)
.replace(
/authorization\s*:\s*(?:bearer\s+)?\S+/giu,
'authorization: [REDACTED]',
)
.replace(/bearer\s+\S+/giu, 'Bearer [REDACTED]')
.replace(/(?:api[_-]?key|token)\s*[=:]\s*\S+/giu, '[REDACTED]')
.replace(/https?:\/\/\S+/giu, '<url>')
.replace(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '<path>')
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>');
}
@@ -178,7 +178,7 @@ export function registerAuthTests() {
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(resolveLogin).not.toBeNull());
expect(screen.queryByLabelText('服务器')).toBeNull();
expect(screen.getByLabelText('服务器')).not.toBeNull();
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
@@ -999,7 +999,7 @@ export function registerAuthTests() {
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('shows login without server selection or custom platform address', async () => {
it('shows debug server selection and keeps custom address collapsed by default', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
@@ -1016,12 +1016,12 @@ export function registerAuthTests() {
);
await screen.findByRole('main', { name: '登录' });
expect(screen.queryByRole('combobox', { name: '服务器' })).toBeNull();
expect(screen.getByRole('combobox', { name: '服务器' })).not.toBeNull();
expect(screen.queryByLabelText('自定义服务器地址')).toBeNull();
});
it.each(['release', 'dev'])(
'restores dev without forwarding a bare credential despite the %s preference',
'restores the selected server without forwarding a bare credential (%s)',
async (preset) => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
@@ -1078,7 +1078,10 @@ export function registerAuthTests() {
'install_platform_account_session',
expect.objectContaining({
accessToken: 'dev-token',
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
apiBaseUrl:
preset === 'release'
? 'https://www.genarrative.world'
: AGC_DEVELOPMENT_API_BASE_URL,
}),
);
},
@@ -100,20 +100,22 @@ describe('AGC platform credential origin', () => {
expect(window.localStorage.getItem(originKey)).toBeNull();
});
it('stores new dev credentials with their origin and ignores old preferences', () => {
vi.stubEnv('DEV', false);
it('stores credentials with their origin and keeps debug server preferences', () => {
setStoredAuthAccessToken('new-token');
window.localStorage.setItem(
selectionKey,
JSON.stringify({ preset: 'release' }),
);
expect(getApiAccessToken()).toBe('new-token');
expect(getStoredAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL)).toBe(
'new-token',
);
expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe('');
expect(() =>
setStoredAuthAccessToken('wrong-token', 'https://example.com'),
).toThrow('固定的 dev 服务');
expect(getStoredAuthAccessToken()).toBe('new-token');
setStoredAuthAccessToken('release-token', 'https://www.genarrative.world');
expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe(
'release-token',
);
expect(window.localStorage.getItem(selectionKey)).toContain('release');
clearStoredAuthAccessToken();
expect(window.localStorage.getItem(tokenKey)).toBeNull();
expect(window.localStorage.getItem(originKey)).toBeNull();
@@ -120,7 +120,7 @@ describe('AGC client HTTP transport', () => {
});
it.each(['development', 'production'])(
'routes %s Tauri requests through fixed dev',
'routes %s Tauri requests through the active channel server',
(mode) => {
expect(
resolveClientHttpTarget('/api/auth/me', {
@@ -143,17 +143,21 @@ describe('AGC client HTTP transport', () => {
).toEqual({ transport: 'web', url: '/api/auth/me' });
});
it('rejects release Tauri requests outside the fixed dev API origin', () => {
expect(() =>
it('routes debug requests through an explicit custom server', () => {
expect(
resolveClientHttpTarget('https://example.com/api/auth/me', {
isTauri: true,
mode: 'production',
mode: 'development',
serverBaseUrl: 'https://example.com',
}),
).toThrow('固定的 dev 服务范围');
).toEqual({
transport: 'tauri-http',
url: 'https://example.com/api/auth/me',
});
});
it.each(['release', 'dev', 'custom'])(
'ignores persisted %s preference when resolving web requests',
'honors persisted %s preference in debug requests',
(preset) => {
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
@@ -162,8 +166,13 @@ describe('AGC client HTTP transport', () => {
customBaseUrl: 'https://staging.example.com',
}),
);
vi.stubEnv('DEV', false);
expect(getClientServerBaseUrl()).toBe(AGC_DEVELOPMENT_API_BASE_URL);
expect(getClientServerBaseUrl()).toBe(
preset === 'release'
? 'https://www.genarrative.world'
: preset === 'custom'
? 'https://staging.example.com'
: AGC_DEVELOPMENT_API_BASE_URL,
);
expect(
resolveClientHttpTarget('/api/auth/me', {
isTauri: false,
@@ -171,30 +180,25 @@ describe('AGC client HTTP transport', () => {
}),
).toEqual({
transport: 'web',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
url: `${getClientServerBaseUrl()}/api/auth/me`,
});
},
);
it.each(['development', 'production', 'test'])(
'rejects explicit origin overrides before transport in %s',
async (mode) => {
vi.stubEnv('MODE', mode);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchClientHttp(
'/api/auth/me',
{},
{
serverBaseUrl: 'https://www.genarrative.world',
},
),
).rejects.toThrow('固定的 dev 服务范围');
expect(fetchMock).not.toHaveBeenCalled();
expect(tauriHttpFetch).not.toHaveBeenCalled();
},
);
it('keeps explicit server origin when sending a debug request', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
await fetchClientHttp(
'/api/auth/me',
{},
{
serverBaseUrl: 'https://www.genarrative.world',
},
);
expect(fetchMock).toHaveBeenCalledWith('/api/auth/me', expect.any(Object));
expect(tauriHttpFetch).not.toHaveBeenCalled();
});
it('aborts a stalled Web request at the configured timeout', async () => {
vi.useFakeTimers();
@@ -88,6 +88,7 @@ import {
getStableErrorReportSubmissionId,
installWebviewLogBridge,
markClientErrorEventsSubmitted,
normalizeDiagnosticText,
resetClientErrorEventsForTests,
shouldCaptureClientError,
submitErrorReportBatch,
@@ -208,6 +209,15 @@ describe('客户端错误报告池', () => {
expect(shouldCaptureClientError({ networkError: true })).toBe(true);
});
it('保留 API 路由但隐藏 URL origin 与查询参数', () => {
const sanitized = normalizeDiagnosticText(
'请求超时:https://dev.genarrative.world/api/llm/models?token=secret#fragment',
);
expect(sanitized).toBe('请求超时:<origin>/api/llm/models');
expect(sanitized).not.toContain('genarrative.world');
expect(sanitized).not.toContain('secret');
});
it('将 WebView console 输出写入普通文本日志 command', async () => {
const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {});
const uninstall = installWebviewLogBridge();
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { useRecentProjects } from '../src/features/app-shell/useRecentProjects';
const READY_PROJECT = {
projectPath: '/tmp/ready-project',
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
godotProjectRoot: null,
isCocosProject: false,
cocosProjectRoot: null,
isUnityProject: false,
unityProjectRoot: null,
projectName: '正常项目',
recentRunStatus: null,
recentRunStopReason: null,
};
afterEach(() => {
delete window.__TAURI__;
window.localStorage.clear();
});
test('刷新坏项目时保留其它项目已确认的正常状态', async () => {
let finishPendingInspection: (() => void) | null = null;
const pendingInspection = new Promise<typeof READY_PROJECT>((resolve) => {
finishPendingInspection = () =>
resolve({
...READY_PROJECT,
projectPath: '/tmp/slow-broken-project',
projectName: '慢速坏项目',
});
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'inspect_local_project_directory') {
throw new Error(`unexpected invoke ${command}`);
}
const projectPath = String(args?.projectPath ?? '');
if (projectPath === '/tmp/slow-broken-project') {
return pendingInspection;
}
return READY_PROJECT;
},
);
window.__TAURI__ = { core: { invoke } };
window.localStorage.setItem(
'genarrative-ai-game-creator.recent-workspaces.v1',
JSON.stringify(['/tmp/ready-project']),
);
const { result } = renderHook(() => useRecentProjects(vi.fn()));
await waitFor(() => {
expect(result.current.projectRows[0]).toMatchObject({
name: '正常项目',
status: '本地项目',
canOpen: true,
});
});
act(() => {
result.current.rememberRecentWorkspace('/tmp/slow-broken-project');
});
await waitFor(() => {
expect(result.current.projectRows).toHaveLength(2);
const readyRow = result.current.projectRows.find(
(row) => row.path === '/tmp/ready-project',
);
const slowRow = result.current.projectRows.find(
(row) => row.path === '/tmp/slow-broken-project',
);
expect(readyRow).toMatchObject({
name: '正常项目',
status: '本地项目',
canOpen: true,
});
expect(slowRow).toMatchObject({
name: 'slow-broken-project',
status: '检查中',
canOpen: false,
});
});
await act(async () => {
finishPendingInspection?.();
await pendingInspection;
});
});