@linhey/rhine-design 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +17 -14
  4. package/components/COMMON_COMPONENTS.md +5 -3
  5. package/components/README.md +13 -5
  6. package/components/SPACING.md +9 -0
  7. package/components/Toast.tsx +10 -4
  8. package/components/common.css +2 -0
  9. package/components/components.tsx +13 -7
  10. package/components/styles.css +12 -0
  11. package/docs/DESIGN_SYSTEM.md +10 -1
  12. package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
  13. package/docs/DISCLOSURE_VERIFICATION.md +49 -0
  14. package/docs/INTERACTION_PATTERNS.md +9 -1
  15. package/docs/REVIEW_TOOL.md +128 -0
  16. package/docs/REVIEW_VERIFICATION.md +56 -0
  17. package/docs/SUMMARY_TABS.md +77 -0
  18. package/docs/THEMING.md +1 -1
  19. package/docs/assets/disclosure/arknights-400.png +0 -0
  20. package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
  21. package/docs/assets/disclosure/rhine-400.png +0 -0
  22. package/docs/assets/disclosure/rhine-native200.png +0 -0
  23. package/examples/review/http/App.tsx +46 -0
  24. package/examples/review/http/catalog.tsx +44 -0
  25. package/examples/review/http/details.ts +1 -0
  26. package/examples/review/http/vite.config.ts +8 -0
  27. package/examples/review/platform/Workspace.tsx +34 -0
  28. package/examples/review/platform/catalog.tsx +46 -0
  29. package/examples/review/platform/vite.config.ts +8 -0
  30. package/examples/review/product.css +3 -0
  31. package/package.json +17 -5
  32. package/preview/CommonGallery.tsx +1 -1
  33. package/preview/GalleryShell.tsx +3 -2
  34. package/preview/OrbitGallery.tsx +1 -1
  35. package/preview/SpacingGallery.tsx +9 -1
  36. package/preview/SummaryTabsGallery.tsx +37 -0
  37. package/preview/main.tsx +7 -3
  38. package/preview/packageInfo.ts +5 -0
  39. package/review/canvas.tsx +107 -0
  40. package/review/dev-client.mjs +17 -0
  41. package/review/feedback.ts +17 -0
  42. package/review/index.ts +2 -0
  43. package/review/isolation.ts +64 -0
  44. package/review/lifecycle.ts +59 -0
  45. package/review/protocol.ts +7 -0
  46. package/review/review.css +41 -0
  47. package/review/shell.tsx +165 -0
  48. package/review/state.ts +41 -0
  49. package/review/types.ts +59 -0
  50. package/review/vite.d.mts +10 -0
  51. package/review/vite.mjs +107 -0
  52. package/scripts/check-review-package.mjs +11 -0
  53. package/scripts/check-review-recovery.js +47 -0
  54. package/scripts/check-review.js +153 -0
  55. package/skills/rhine-design/references/API_MAP.md +4 -0
  56. package/skills/rhine-design/references/TASK_MAP.md +2 -0
  57. package/tests/CommonComponents.test.tsx +8 -0
  58. package/tests/DesignSystem.test.tsx +32 -0
  59. package/tests/Review.test.tsx +117 -0
  60. package/tests/ReviewBuild.test.tsx +59 -0
  61. package/tests/ReviewShell.test.tsx +63 -0
  62. package/tsconfig.json +5 -2
@@ -6,6 +6,7 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
6
6
  | --- | --- | --- |
7
7
  | Application shell, backgrounds, media, advanced inputs, controlled tables | `docs/APPLICATION_COMPONENTS.md` (0.5.0) | `preview/ExtensionsGallery.tsx`; `/#app-shell`, `/#advanced-forms`, `/#advanced-data`, `/#media` |
8
8
  | Workspace header, duplicate page titles, optional breadcrumbs | `docs/APPLICATION_COMPONENTS.md` → 平级工作区顶部操作区 (source guideline) | `preview/ExtensionsGallery.tsx`; `/#app-shell`; keep status/actions, navigation restore and accessible page identity |
9
+ | Product design review shell, synthetic HTTP/platform scenes, static deployment | `docs/REVIEW_TOOL.md` (since 0.11.0; not npm 0.10.0) | `review/`, `examples/review/http`, `examples/review/platform`; separate `/review` and `/review/vite` exports |
9
10
  | Theme integration, switching, controlled preferences | `docs/THEMING.md` (0.4.0) | `components/theme.tsx`, `preview/GalleryShell.tsx` |
10
11
  | Scenario selection, navigation, form recovery, monitoring | `docs/INTERACTION_PATTERNS.md` (0.4.0; check installed version) | `preview/PatternGallery.tsx`; research in `docs/REFERENCE_RESEARCH.md` |
11
12
  | Date presets, visible date ranges, custom-range drafts and confirmation | `docs/DATE_RANGE_FILTER.md` (source pattern using existing 0.6.0 APIs) | `preview/DateRangeFilterGallery.tsx`; `/#date-range-filter`; no public DateRangeFilter component |
@@ -14,6 +15,7 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
14
15
  | Text hierarchy, line height, paragraphs, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx`, `preview/LineHeightGallery.tsx`; `/#typography` |
15
16
  | Logo, light/dark variants, wordmark | `components/README.md` → 品牌标志 | `components/Logo.tsx`, `components/assets/logo.svg` |
16
17
  | Buttons, navigation, basic states | `components/README.md` → 组件契约 | `components/components.tsx` |
18
+ | Tabs with identity summaries and per-object status | `docs/SUMMARY_TABS.md` (local candidate; verify installed API) | `Tabs.description/status`, `preview/SummaryTabsGallery.tsx`; `/#summary-tabs`; 12/8/4/16/24 spacing, stable labels and accessible descriptions |
17
19
  | Task-centered content reduction, borderless account summaries, credential actions, compact legends | `docs/COMPACT_ACCOUNT_PATTERNS.md` (source candidates; not npm 0.6.0) | `preview/CompactAccountGallery.tsx`; `/#compact-account`; ButtonLink + StatusTimelineList + TimelineLegend |
18
20
  | Forms, selection, combobox, data tables | `components/COMMON_COMPONENTS.md` | Use API_MAP for the source file |
19
21
  | Modal, menu, popover, tooltip, feedback | `components/COMMON_COMPONENTS.md` | `components/Overlays.tsx`, `components/Toast.tsx` |
@@ -59,6 +59,14 @@ describe('common design components', () => {
59
59
  act(()=>vi.advanceTimersByTime(2000));fireEvent.mouseEnter(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();fireEvent.mouseLeave(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(2999));expect(close).not.toHaveBeenCalled();act(()=>vi.advanceTimersByTime(1));expect(close).toHaveBeenCalledWith(false);
60
60
  close.mockClear();rerender(<Toast open onOpenChange={close} title="失败" tone="error" duration={1000}/>);act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();
61
61
  });
62
+ it('portals floating feedback outside the content panel while preserving theme and focus', () => {
63
+ const {container,unmount}=render(<div className="rhine-theme"><section><button>继续</button><Toast placement="floating" open onOpenChange={()=>{}} title="完成"/></section></div>);
64
+ const trigger=screen.getByRole('button',{name:'继续'});trigger.focus();
65
+ const region=screen.getByRole('status').parentElement!;
66
+ expect(region.parentElement).toBe(container.querySelector('.rhine-theme'));
67
+ expect(container.querySelector('section')).not.toContainElement(region);
68
+ expect(trigger).toHaveFocus();unmount();expect(screen.queryByText('完成')).toBeNull();
69
+ });
62
70
  it('resets toast countdown when reopening', () => {
63
71
  vi.useFakeTimers();function Demo(){const [open,setOpen]=useState(true);return <><button onClick={()=>setOpen(true)}>显示</button><Toast open={open} onOpenChange={setOpen} title="通知" duration={1000}/></>;}
64
72
  render(<Demo/>);act(()=>vi.advanceTimersByTime(1000));expect(screen.queryByText('通知')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'显示'}));act(()=>vi.advanceTimersByTime(999));expect(screen.getByText('通知')).toBeVisible();act(()=>vi.advanceTimersByTime(1));expect(screen.queryByText('通知')).not.toBeInTheDocument();
@@ -82,6 +82,21 @@ describe('reusable design components', () => {
82
82
  const tabs=screen.getAllByRole('tab'); expect(tabs[0].id).not.toBe(tabs[1].id);
83
83
  for(const tab of tabs) expect(document.getElementById(tab.getAttribute('aria-controls')!)).toHaveAttribute('aria-labelledby',tab.id);
84
84
  });
85
+ it('keeps tab names stable while associating independent descriptions and status', () => {
86
+ const { rerender } = render(<Tabs label="连接" value="a" onValueChange={()=>{}} items={[
87
+ {value:'a',label:'甲',description:'账号一',status:<span>同步中</span>,content:'甲内容'},
88
+ {value:'b',label:'乙',description:'账号二',status:<span>已停止</span>,content:'乙内容'},
89
+ ]}/>);
90
+ const first=screen.getByRole('tab',{name:'甲'}); first.focus();
91
+ expect(first).toHaveAccessibleDescription('账号一 同步中');
92
+ expect(screen.getByRole('tab',{name:'乙'})).toHaveAccessibleDescription('账号二 已停止');
93
+ rerender(<Tabs label="连接" value="a" onValueChange={()=>{}} items={[
94
+ {value:'a',label:'甲',description:'账号三',status:<span>已停止</span>,content:'甲内容'},
95
+ {value:'b',label:'乙',content:'乙内容'},
96
+ ]}/>);
97
+ expect(first).toHaveFocus(); expect(first).toHaveAccessibleDescription('账号三 已停止');
98
+ expect(screen.getByRole('tab',{name:'乙'})).not.toHaveAttribute('aria-describedby');
99
+ });
85
100
  it('keeps separate segmented controls in independent native radio groups', () => {
86
101
  render(<>{['a','b'].map(label=><SegmentedControl key={label} label={label} value="one" onValueChange={()=>{}} items={[{value:'one',label:'一'},{value:'two',label:'二'}]}/>)}</>);
87
102
  const radios=screen.getAllByRole('radio'); expect(radios[0].getAttribute('name')).not.toBe(radios[2].getAttribute('name'));
@@ -108,4 +123,21 @@ describe('reusable design components', () => {
108
123
  expect(details.open).toBe(false);fireEvent.click(summary);expect(details.open).toBe(true);
109
124
  fireEvent.click(summary);expect(details.open).toBe(false);expect(screen.getByText('内部按钮')).not.toBeVisible();
110
125
  });
126
+ it.each([['200ms', 200], ['.2s', 200], ['0ms', 0], ['0s', 0]])('disclosure respects CSS duration %s after production normalization', (cssDuration, milliseconds) => {
127
+ render(<Disclosure title="动画时长">内容</Disclosure>);
128
+ const summary = screen.getByText('动画时长');
129
+ const details = summary.closest('details')!;
130
+ details.style.setProperty('--rhine-duration-notice', cssDuration);
131
+ const animate = vi.fn(() => ({ cancel: vi.fn(), onfinish: null } as unknown as Animation));
132
+ details.animate = animate;
133
+ fireEvent.click(summary);
134
+ if (milliseconds === 0) {
135
+ expect(animate).not.toHaveBeenCalled();
136
+ expect(details.open).toBe(true);
137
+ fireEvent.click(summary);
138
+ expect(details.open).toBe(false);
139
+ } else {
140
+ expect(animate).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ duration: milliseconds }));
141
+ }
142
+ });
111
143
  });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { defineReview, parseReviewState, serializeReviewState } from '../review/state';
3
+ import { ReviewLifecycle } from '../review/lifecycle';
4
+ import { createMockFetch, MemoryStorage } from '../review/isolation';
5
+ import type { ReviewContext, ReviewScene, ReviewSession } from '../review/types';
6
+
7
+ const session = (): ReviewSession => ({ render: () => null, dispose: vi.fn() });
8
+ const scene: ReviewScene = { id: 'projects', title: '项目', setup: session, motionControls: ['full', 'paused'], dimensions: [
9
+ { id: 'status', label: '状态', options: [{ id: 'normal', label: '正常' }, { id: 'error', label: '错误' }] },
10
+ { id: 'role', label: '角色', options: [{ id: 'editor', label: '编辑' }, { id: 'reader', label: '只读' }] },
11
+ ] };
12
+ const catalog = defineReview({ title: 'Review', scenes: [scene, { id: 'other', title: '其他', setup: session }] });
13
+ describe('review URL contract', () => {
14
+ it('restores independent dimensions and all controls', () => {
15
+ const hash = '#scene=projects&d.status=error&d.role=reader&theme=arknights&width=520&zoom=2&motion=paused';
16
+ const state = parseReviewState(hash, catalog);
17
+ expect(state).toEqual({ scene: 'projects', dimensions: { status: 'error', role: 'reader' }, theme: 'arknights', width: 520, zoom: 2, motion: 'paused' });
18
+ expect(parseReviewState(serializeReviewState(state), catalog)).toEqual(state);
19
+ });
20
+ it('drops unknown values and arbitrary form drafts with deterministic fallback', () => {
21
+ const state = parseReviewState('#scene=gone&theme=unknown&width=999&zoom=5&motion=bad&d.role=admin&draft=secret', catalog);
22
+ expect(state).toEqual({ scene: 'projects', dimensions: { status: 'normal', role: 'editor' }, theme: 'rhine', width: 'fit', zoom: 1, motion: 'system' });
23
+ expect(serializeReviewState(state)).not.toContain('secret');
24
+ });
25
+ it('does not leak dimensions to a scene which does not support them', () => {
26
+ expect(parseReviewState('#scene=other&d.role=reader', catalog).dimensions).toEqual({});
27
+ });
28
+ it('rejects duplicate IDs, empty dimensions and invalid widths', () => {
29
+ expect(() => defineReview({ title: '', scenes: [] })).toThrow();
30
+ expect(() => defineReview({ title: '', scenes: [scene, scene] })).toThrow();
31
+ expect(() => defineReview({ title: '', scenes: [{ ...scene, dimensions: [{ id: 'x', label: '', options: [] }] }] })).toThrow();
32
+ expect(() => defineReview({ title: '', scenes: [scene], widths: [0] })).toThrow();
33
+ });
34
+ });
35
+ const deferred = <T,>() => { let resolve!: (value: T) => void; const promise = new Promise<T>(yes => { resolve = yes; }); return { promise, resolve }; };
36
+ function harness() {
37
+ const hooks = { clear: vi.fn(), ready: vi.fn(), error: vi.fn(), context: () => ({ mockHttp: vi.fn(), report: vi.fn() }) };
38
+ return { hooks, lifecycle: new ReviewLifecycle(hooks) };
39
+ }
40
+ describe('review lifecycle', () => {
41
+ it('aborts pending setup and cleans resources registered before it returns', async () => {
42
+ const { lifecycle, hooks } = harness();
43
+ const pending = deferred<ReviewSession>();
44
+ const earlyCleanup = vi.fn();
45
+ let context!: ReviewContext;
46
+ const first = lifecycle.select({ ...scene, setup: ctx => { context = ctx; ctx.onDispose(earlyCleanup); return pending.promise; } }, {});
47
+ await lifecycle.select(catalog.scenes[1], {});
48
+ expect(context.signal.aborted).toBe(true);
49
+ expect(earlyCleanup).toHaveBeenCalledOnce();
50
+ const stale = session(); pending.resolve(stale); await first;
51
+ expect(stale.dispose).toHaveBeenCalledOnce();
52
+ expect(hooks.ready).toHaveBeenCalledTimes(1);
53
+ await lifecycle.dispose();
54
+ });
55
+ it('waits for async disposal across rapid consecutive selections', async () => {
56
+ const { lifecycle, hooks } = harness(); const disposal = deferred<void>();
57
+ await lifecycle.select({ ...scene, setup: () => ({ ...session(), dispose: () => disposal.promise }) }, {});
58
+ const discarded = vi.fn(session), accepted = vi.fn(session);
59
+ const second = lifecycle.select({ ...scene, setup: discarded }, {});
60
+ const third = lifecycle.select({ ...scene, setup: accepted }, {});
61
+ await Promise.resolve(); expect(accepted).not.toHaveBeenCalled();
62
+ disposal.resolve(); await Promise.all([second, third]);
63
+ expect(discarded).not.toHaveBeenCalled(); expect(accepted).toHaveBeenCalledOnce();
64
+ expect(hooks.error).not.toHaveBeenCalled(); await lifecycle.dispose();
65
+ });
66
+ it('resets then disposes and creates a fresh session', async () => {
67
+ const { lifecycle } = harness(); const log: string[] = [];
68
+ const definition = { ...scene, setup: (context: ReviewContext) => { log.push(`setup:${context.reason}`); return { render: () => null, reset: () => { log.push('reset'); }, dispose: () => { log.push('dispose'); } }; } };
69
+ await lifecycle.select(definition, {}); await lifecycle.select(definition, {}, 'reset');
70
+ expect(log).toEqual(['setup:select', 'reset', 'dispose', 'setup:reset']); await lifecycle.dispose();
71
+ });
72
+ it('runs cleanup on setup rejection and surfaces failure', async () => {
73
+ const { lifecycle, hooks } = harness(); const cleanup = vi.fn();
74
+ await lifecycle.select({ ...scene, setup: async context => { context.onDispose(cleanup); throw new Error('setup failed'); } }, {});
75
+ expect(cleanup).toHaveBeenCalledOnce(); expect(hooks.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'setup failed' }), 'projects');
76
+ });
77
+ it('attributes a disposal failure to the old scene while accepting the next scene', async () => {
78
+ const { lifecycle, hooks } = harness();
79
+ await lifecycle.select({ ...scene, setup: () => ({ ...session(), dispose() { throw new Error('cleanup failed'); } }) }, {});
80
+ await lifecycle.select(catalog.scenes[1], {});
81
+ expect(hooks.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'cleanup failed' }), 'projects');
82
+ expect(hooks.ready).toHaveBeenLastCalledWith(expect.anything(), catalog.scenes[1]);
83
+ await lifecycle.dispose();
84
+ });
85
+ });
86
+ describe('synthetic request boundary', () => {
87
+ it('preserves Request method/body and omits credentials', async () => {
88
+ const handler = vi.fn(async (request: Request) => {
89
+ expect(request.method).toBe('POST'); expect(request.credentials).toBe('omit');
90
+ return Response.json({ body: await request.text() });
91
+ });
92
+ const fetch = createMockFetch(() => handler, () => new AbortController().signal, vi.fn());
93
+ const response = await fetch(new Request('https://synthetic.invalid/items', { method: 'POST', body: 'hello' }));
94
+ expect(await response.json()).toEqual({ body: 'hello' });
95
+ });
96
+ it('rejects unmatched requests with visible failure instead of fallback network', async () => {
97
+ const report = vi.fn(); const fetch = createMockFetch(() => undefined, () => new AbortController().signal, report);
98
+ await expect(fetch(new URL('https://synthetic.invalid/missing'))).rejects.toThrow('未实现模拟请求');
99
+ expect(report).toHaveBeenCalledWith('未实现模拟请求:GET /missing');
100
+ });
101
+ it('cancels a noncooperative handler when its scene is disposed', async () => {
102
+ const abort = new AbortController(); const fetch = createMockFetch(() => () => new Promise(() => {}), () => abort.signal, vi.fn());
103
+ const pending = fetch('https://synthetic.invalid/items'); abort.abort();
104
+ await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
105
+ });
106
+ it('honors a Request abort signal and a pre-aborted request', async () => {
107
+ const abort = new AbortController(); const handler = vi.fn(() => new Promise<Response>(() => {}));
108
+ const fetch = createMockFetch(() => handler, () => new AbortController().signal, vi.fn());
109
+ abort.abort(); await expect(fetch(new Request('https://synthetic.invalid', { signal: abort.signal }))).rejects.toMatchObject({ name: 'AbortError' });
110
+ expect(handler).not.toHaveBeenCalled();
111
+ });
112
+ it('keeps storage in memory and resets all keys', () => {
113
+ const storage = new MemoryStorage(); storage.setItem('name', 'synthetic');
114
+ expect(storage.getItem('name')).toBe('synthetic'); expect(storage.length).toBe(1);
115
+ storage.clear(); expect(storage.getItem('name')).toBeNull(); expect(storage.key(0)).toBeNull();
116
+ });
117
+ });
@@ -0,0 +1,59 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { build, preview } from 'vite';
7
+ import react from '@vitejs/plugin-react';
8
+ import { reviewPlugin, reviewProductionGuard } from '../review/vite.mjs';
9
+
10
+ const directories: string[] = [];
11
+ async function fixture() { const path = await mkdtemp(join(tmpdir(), 'rhine-review-test-')); directories.push(path); return path; }
12
+ afterEach(async () => { await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
13
+ describe('review build integration', () => {
14
+ it.each(['/nested/review/', './'])('emits final CSS and usable preview for base %s', async base => {
15
+ const root = await fixture();
16
+ await build({ configFile: false, root, base, logLevel: 'silent', plugins: [react(), reviewPlugin({ catalog: resolve('examples/review/http/catalog.tsx') })] });
17
+ for (const name of ['index.html', 'canvas.html']) {
18
+ const html = await readFile(join(root, 'dist', name), 'utf8');
19
+ const stylesheet = html.match(/rel="stylesheet" href="([^\"]+\.css)"/)?.[1];
20
+ expect(stylesheet?.startsWith(base)).toBe(true);
21
+ const css = stylesheet?.slice(base.length);
22
+ expect(css).toBeTruthy();
23
+ expect(await readFile(join(root, 'dist', css!), 'utf8')).toContain('.rhine-review-header');
24
+ expect(html).toContain("connect-src 'none'");
25
+ expect(html).toContain(`src="${base}assets/`);
26
+ }
27
+ const server = await preview({ configFile: false, root, base, logLevel: 'silent', plugins: [reviewPlugin({ catalog: resolve('examples/review/http/catalog.tsx') })], preview: { host: '127.0.0.1', port: 0 } });
28
+ try {
29
+ const address = server.httpServer.address();
30
+ if (!address || typeof address === 'string') throw new Error('Missing preview port');
31
+ const url = `http://127.0.0.1:${address.port}${base === './' ? '/' : base}`;
32
+ const response = await fetch(url);
33
+ expect(response.status).toBe(200);
34
+ expect(await response.text()).toContain('Rhine Review');
35
+ expect((await fetch(`${url}api/missing`)).status).toBe(404);
36
+ } finally { await new Promise<void>((resolve, reject) => server.httpServer.close(error => error ? reject(error) : resolve())); }
37
+ }, 20000);
38
+ it('allows production theme directories but rejects explicit fixture paths', async () => {
39
+ const root = await fixture();
40
+ await mkdir(join(root, 'design')); await mkdir(join(root, 'fixtures'));
41
+ await writeFile(join(root, 'design/theme.js'), 'export const theme = "synthetic-theme";');
42
+ await writeFile(join(root, 'fixtures/data.js'), 'export const data = "synthetic-fixture";');
43
+ const entry = join(root, 'entry.js');
44
+ const options = { configFile: false as const, root, logLevel: 'silent' as const, plugins: [reviewProductionGuard({ forbidden: ['./fixtures'] })], build: { lib: { entry, formats: ['es' as const] } } };
45
+ await writeFile(entry, 'export {theme} from "./design/theme.js";');
46
+ await expect(build(options)).resolves.toBeTruthy();
47
+ await writeFile(entry, 'export {data} from "./fixtures/data.js";');
48
+ await expect(build(options)).rejects.toThrow('forbidden fixtures: fixtures/data.js');
49
+ }, 20000);
50
+ it('rejects accidental official runtime inclusion without a product denylist', async () => {
51
+ const root = await fixture(); const entry = join(root, 'entry.js');
52
+ await writeFile(entry, `export { defineReview } from ${JSON.stringify(resolve('review/index.ts'))};`);
53
+ await expect(build({ configFile: false, root, logLevel: 'silent', plugins: [reviewProductionGuard()], build: { lib: { entry, formats: ['es'] } } })).rejects.toThrow('Production bundle imports review runtime');
54
+ }, 20000);
55
+ it('refuses backend proxy and project env configuration', async () => {
56
+ const root = await fixture();
57
+ await expect(build({ configFile: false, root, logLevel: 'silent', plugins: [reviewPlugin({ catalog: './catalog.tsx' })], server: { proxy: { '/api': 'http://synthetic.invalid' } } })).rejects.toThrow('no server/preview proxies');
58
+ });
59
+ });
@@ -0,0 +1,63 @@
1
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { ReviewShell, reviewWaitMs } from '../review/shell';
4
+ import { appendFeedback, emptyFeedback } from '../review/feedback';
5
+ import { protocol } from '../review/protocol';
6
+
7
+ const catalog = { title: '故障恢复', scenes: [{ id: 'a', title: '场景 A' }, { id: 'b', title: '场景 B' }] };
8
+ function iframe() { return screen.getByTitle('产品场景画布') as HTMLIFrameElement; }
9
+ function send(data: Record<string, unknown>, source = iframe().contentWindow!) {
10
+ fireEvent(window, new MessageEvent('message', { origin: location.origin, source, data: { protocol, ...data } }));
11
+ }
12
+ function connect() {
13
+ const post = vi.spyOn(iframe().contentWindow!, 'postMessage');
14
+ send({ type: 'ready', catalog });
15
+ return { post, request: () => post.mock.calls.filter(call => call[0].type === 'select').at(-1)![0].requestId as number };
16
+ }
17
+ beforeEach(() => { vi.useFakeTimers(); history.replaceState(null, '', '#scene=a'); });
18
+ afterEach(() => { cleanup(); vi.useRealTimers(); });
19
+ describe('review shell recovery', () => {
20
+ it('offers recovery when the initial iframe never handshakes', () => {
21
+ render(<ReviewShell />); const oldFrame = iframe();
22
+ act(() => vi.advanceTimersByTime(reviewWaitMs));
23
+ expect(screen.getByRole('alert')).toHaveTextContent('连接画布超时');
24
+ fireEvent.click(screen.getByRole('button', { name: '重新载入画布' }));
25
+ expect(iframe()).not.toBe(oldFrame);
26
+ const { request } = connect(); send({ type: 'rendered', requestId: request() });
27
+ expect(screen.getByRole('status')).toHaveTextContent('已就绪');
28
+ expect(screen.getByRole('alert')).toHaveTextContent('连接画布超时');
29
+ });
30
+ it('replaces a stuck browsing context, keeps selection, ignores old acknowledgements', () => {
31
+ render(<ReviewShell />); const { request } = connect();
32
+ send({ type: 'rendered', requestId: request() });
33
+ fireEvent.click(screen.getByRole('link', { name: '场景 B' }));
34
+ const pending = request();
35
+ send({ type: 'rendered', requestId: pending - 1 });
36
+ act(() => vi.advanceTimersByTime(reviewWaitMs));
37
+ expect(screen.getByRole('alert')).toHaveTextContent('初始化或清理超过');
38
+ const oldFrame = iframe(), oldWindow = oldFrame.contentWindow!;
39
+ fireEvent.click(screen.getByRole('button', { name: '重新载入画布' }));
40
+ expect(oldFrame.isConnected).toBe(false); expect(location.hash).toContain('scene=b');
41
+ send({ type: 'feedback', kind: 'error', message: 'stale error' }, oldWindow);
42
+ expect(screen.queryByText(/stale error/)).not.toBeInTheDocument();
43
+ const next = connect(); send({ type: 'rendered', requestId: next.request() });
44
+ expect(screen.getByRole('status')).toHaveTextContent('已就绪');
45
+ });
46
+ it('retains scoped errors across ready and selection until explicit acknowledgement', () => {
47
+ render(<ReviewShell />); const { request } = connect();
48
+ send({ type: 'feedback', scene: 'a', kind: 'error', message: '清理失败 A' });
49
+ send({ type: 'rendered', requestId: request() });
50
+ fireEvent.click(screen.getByRole('link', { name: '场景 B' }));
51
+ send({ type: 'rendered', requestId: request() });
52
+ expect(screen.getByRole('alert')).toHaveTextContent('清理失败 A');
53
+ fireEvent.click(screen.getByText(/评审反馈 ·/));
54
+ expect(screen.getByText('场景 A', { selector: 'strong' })).toBeInTheDocument();
55
+ fireEvent.click(screen.getByRole('button', { name: '确认并清除反馈' }));
56
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
57
+ });
58
+ it('does not silently lose unacknowledged errors when bounded history overflows', () => {
59
+ let log = emptyFeedback();
60
+ for (let id = 0; id < 30; id++) log = appendFeedback(log, { id, kind: id < 4 ? 'error' : 'info', message: String(id), scene: 'a' });
61
+ expect(log.entries).toHaveLength(20); expect(log.errors).toBe(4); expect(log.omittedErrors).toBe(4);
62
+ });
63
+ });
package/tsconfig.json CHANGED
@@ -20,13 +20,16 @@
20
20
  "types": [
21
21
  "vite/client",
22
22
  "node"
23
- ]
23
+ ],
24
+ "allowJs": true
24
25
  },
25
26
  "include": [
26
27
  "components",
27
28
  "preview",
28
29
  "tests",
29
30
  "vite.config.ts",
30
- "vitest.config.ts"
31
+ "vitest.config.ts",
32
+ "review",
33
+ "examples/review"
31
34
  ]
32
35
  }