@lobehub/chat 1.141.5 → 1.141.7

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 (40) hide show
  1. package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
  2. package/.github/workflows/e2e.yml +6 -6
  3. package/.github/workflows/test.yml +6 -8
  4. package/CHANGELOG.md +42 -0
  5. package/changelog/v1.json +14 -0
  6. package/e2e/README.md +143 -0
  7. package/e2e/cucumber.config.js +20 -0
  8. package/e2e/package.json +24 -0
  9. package/e2e/src/features/discover/smoke.feature +11 -0
  10. package/e2e/src/features/routes/core-routes.feature +43 -0
  11. package/e2e/src/steps/common/navigation.steps.ts +36 -0
  12. package/e2e/src/steps/discover/smoke.steps.ts +34 -0
  13. package/e2e/src/steps/hooks.ts +69 -0
  14. package/e2e/src/steps/routes/routes.steps.ts +41 -0
  15. package/e2e/src/support/webServer.ts +96 -0
  16. package/e2e/src/support/world.ts +76 -0
  17. package/e2e/tsconfig.json +19 -0
  18. package/package.json +6 -3
  19. package/packages/const/src/layoutTokens.ts +1 -1
  20. package/packages/database/src/models/__tests__/session.test.ts +108 -0
  21. package/packages/database/src/models/session.ts +41 -1
  22. package/packages/model-bank/src/aiModels/groq.ts +0 -17
  23. package/packages/model-bank/src/aiModels/novita.ts +2 -60
  24. package/packages/model-bank/src/aiModels/siliconcloud.ts +116 -17
  25. package/pnpm-workspace.yaml +1 -0
  26. package/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx +1 -0
  27. package/src/app/[variants]/(main)/discover/DiscoverRouter.tsx +12 -10
  28. package/src/app/[variants]/(main)/discover/[[...path]]/page.tsx +7 -6
  29. package/src/app/[variants]/(main)/discover/features/Search.tsx +1 -0
  30. package/src/components/Loading/index.ts +1 -0
  31. package/src/features/AgentSetting/AgentModal/index.tsx +262 -35
  32. package/src/features/ChatInput/ActionBar/Params/Controls.tsx +261 -50
  33. package/src/features/ModelParamsControl/FrequencyPenalty.tsx +8 -3
  34. package/src/features/ModelParamsControl/PresencePenalty.tsx +8 -3
  35. package/src/features/ModelParamsControl/Temperature.tsx +8 -5
  36. package/src/features/ModelParamsControl/TopP.tsx +8 -3
  37. package/src/layout/GlobalProvider/Query.tsx +1 -2
  38. package/src/services/chat/index.ts +6 -0
  39. package/e2e/routes.spec.ts +0 -73
  40. package/playwright.config.ts +0 -35
@@ -1,7 +1,10 @@
1
1
  import { Form, type FormItemProps, Tag } from '@lobehub/ui';
2
+ import { Form as AntdForm, Checkbox } from 'antd';
3
+ import { createStyles } from 'antd-style';
2
4
  import isEqual from 'fast-deep-equal';
3
5
  import { debounce } from 'lodash-es';
4
- import { memo } from 'react';
6
+ import { memo, useCallback, useEffect, useRef } from 'react';
7
+ import type { ComponentType } from 'react';
5
8
  import { useTranslation } from 'react-i18next';
6
9
  import { Flexbox } from 'react-layout-kit';
7
10
 
@@ -20,75 +23,283 @@ interface ControlsProps {
20
23
  setUpdating: (updating: boolean) => void;
21
24
  updating: boolean;
22
25
  }
26
+
27
+ type ParamKey = 'temperature' | 'top_p' | 'presence_penalty' | 'frequency_penalty';
28
+
29
+ type ParamLabelKey =
30
+ | 'settingModel.temperature.title'
31
+ | 'settingModel.topP.title'
32
+ | 'settingModel.presencePenalty.title'
33
+ | 'settingModel.frequencyPenalty.title';
34
+
35
+ type ParamDescKey =
36
+ | 'settingModel.temperature.desc'
37
+ | 'settingModel.topP.desc'
38
+ | 'settingModel.presencePenalty.desc'
39
+ | 'settingModel.frequencyPenalty.desc';
40
+
41
+ const useStyles = createStyles(({ css, token }) => ({
42
+ checkbox: css`
43
+ .ant-checkbox-inner {
44
+ border-radius: 4px;
45
+ }
46
+
47
+ &:hover .ant-checkbox-inner {
48
+ border-color: ${token.colorPrimary};
49
+ }
50
+ `,
51
+ label: css`
52
+ user-select: none;
53
+ `,
54
+ sliderWrapper: css`
55
+ display: flex;
56
+ gap: 16px;
57
+ align-items: center;
58
+ width: 100%;
59
+ `,
60
+ }));
61
+
62
+ // Wrapper component to handle checkbox + slider
63
+ interface ParamControlWrapperProps {
64
+ Component: ComponentType<any>;
65
+ checked: boolean;
66
+ disabled: boolean;
67
+ onChange?: (value: number) => void;
68
+ onToggle: (checked: boolean) => void;
69
+ styles: any;
70
+ value?: number;
71
+ }
72
+
73
+ const ParamControlWrapper = memo<ParamControlWrapperProps>(
74
+ ({ Component, value, onChange, disabled, checked, onToggle, styles }) => {
75
+ return (
76
+ <div className={styles.sliderWrapper}>
77
+ <Checkbox
78
+ checked={checked}
79
+ className={styles.checkbox}
80
+ onChange={(e) => {
81
+ e.stopPropagation();
82
+ onToggle(e.target.checked);
83
+ }}
84
+ />
85
+ <div style={{ flex: 1 }}>
86
+ <Component disabled={disabled} onChange={onChange} value={value} />
87
+ </div>
88
+ </div>
89
+ );
90
+ },
91
+ );
92
+
93
+ const PARAM_NAME_MAP: Record<ParamKey, (string | number)[]> = {
94
+ frequency_penalty: ['params', 'frequency_penalty'],
95
+ presence_penalty: ['params', 'presence_penalty'],
96
+ temperature: ['params', 'temperature'],
97
+ top_p: ['params', 'top_p'],
98
+ };
99
+
100
+ const PARAM_DEFAULTS: Record<ParamKey, number> = {
101
+ frequency_penalty: 0,
102
+ presence_penalty: 0,
103
+ temperature: 0.7,
104
+ top_p: 1,
105
+ };
106
+
107
+ const PARAM_CONFIG = {
108
+ frequency_penalty: {
109
+ Component: FrequencyPenalty,
110
+ descKey: 'settingModel.frequencyPenalty.desc',
111
+ labelKey: 'settingModel.frequencyPenalty.title',
112
+ tag: 'frequency_penalty',
113
+ },
114
+ presence_penalty: {
115
+ Component: PresencePenalty,
116
+ descKey: 'settingModel.presencePenalty.desc',
117
+ labelKey: 'settingModel.presencePenalty.title',
118
+ tag: 'presence_penalty',
119
+ },
120
+ temperature: {
121
+ Component: Temperature,
122
+ descKey: 'settingModel.temperature.desc',
123
+ labelKey: 'settingModel.temperature.title',
124
+ tag: 'temperature',
125
+ },
126
+ top_p: {
127
+ Component: TopP,
128
+ descKey: 'settingModel.topP.desc',
129
+ labelKey: 'settingModel.topP.title',
130
+ tag: 'top_p',
131
+ },
132
+ } satisfies Record<
133
+ ParamKey,
134
+ {
135
+ Component: ComponentType<any>;
136
+ descKey: ParamDescKey;
137
+ labelKey: ParamLabelKey;
138
+ tag: string;
139
+ }
140
+ >;
141
+
23
142
  const Controls = memo<ControlsProps>(({ setUpdating }) => {
24
143
  const { t } = useTranslation('setting');
25
144
  const mobile = useServerConfigStore((s) => s.isMobile);
26
145
  const updateAgentConfig = useAgentStore((s) => s.updateAgentConfig);
146
+ const { styles } = useStyles();
27
147
 
28
148
  const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
149
+ const [form] = Form.useForm();
29
150
 
30
- const items: FormItemProps[] = [
31
- {
32
- children: <Temperature />,
33
- label: (
34
- <Flexbox align={'center'} gap={8} horizontal justify={'space-between'}>
35
- {t('settingModel.temperature.title')}
36
- <InfoTooltip title={t('settingModel.temperature.desc')} />
37
- </Flexbox>
38
- ),
39
- name: ['params', 'temperature'],
40
- tag: 'temperature',
41
- },
42
- {
43
- children: <TopP />,
44
- label: (
45
- <Flexbox gap={8} horizontal>
46
- {t('settingModel.topP.title')}
47
- <InfoTooltip title={t('settingModel.topP.desc')} />
48
- </Flexbox>
49
- ),
50
- name: ['params', 'top_p'],
51
- tag: 'top_p',
151
+ const { frequency_penalty, presence_penalty, temperature, top_p } = config.params ?? {};
152
+
153
+ const lastValuesRef = useRef<Record<ParamKey, number | undefined>>({
154
+ frequency_penalty,
155
+ presence_penalty,
156
+ temperature,
157
+ top_p,
158
+ });
159
+
160
+ useEffect(() => {
161
+ form.setFieldsValue(config);
162
+
163
+ if (typeof temperature === 'number') lastValuesRef.current.temperature = temperature;
164
+ if (typeof top_p === 'number') lastValuesRef.current.top_p = top_p;
165
+ if (typeof presence_penalty === 'number') {
166
+ lastValuesRef.current.presence_penalty = presence_penalty;
167
+ }
168
+ if (typeof frequency_penalty === 'number') {
169
+ lastValuesRef.current.frequency_penalty = frequency_penalty;
170
+ }
171
+ }, [config, form, frequency_penalty, presence_penalty, temperature, top_p]);
172
+
173
+ const temperatureValue = AntdForm.useWatch(PARAM_NAME_MAP.temperature, form);
174
+ const topPValue = AntdForm.useWatch(PARAM_NAME_MAP.top_p, form);
175
+ const presencePenaltyValue = AntdForm.useWatch(PARAM_NAME_MAP.presence_penalty, form);
176
+ const frequencyPenaltyValue = AntdForm.useWatch(PARAM_NAME_MAP.frequency_penalty, form);
177
+
178
+ useEffect(() => {
179
+ if (typeof temperatureValue === 'number') lastValuesRef.current.temperature = temperatureValue;
180
+ }, [temperatureValue]);
181
+
182
+ useEffect(() => {
183
+ if (typeof topPValue === 'number') lastValuesRef.current.top_p = topPValue;
184
+ }, [topPValue]);
185
+
186
+ useEffect(() => {
187
+ if (typeof presencePenaltyValue === 'number') {
188
+ lastValuesRef.current.presence_penalty = presencePenaltyValue;
189
+ }
190
+ }, [presencePenaltyValue]);
191
+
192
+ useEffect(() => {
193
+ if (typeof frequencyPenaltyValue === 'number') {
194
+ lastValuesRef.current.frequency_penalty = frequencyPenaltyValue;
195
+ }
196
+ }, [frequencyPenaltyValue]);
197
+
198
+ const enabledMap: Record<ParamKey, boolean> = {
199
+ frequency_penalty: typeof frequencyPenaltyValue === 'number',
200
+ presence_penalty: typeof presencePenaltyValue === 'number',
201
+ temperature: typeof temperatureValue === 'number',
202
+ top_p: typeof topPValue === 'number',
203
+ };
204
+
205
+ const handleToggle = useCallback(
206
+ async (key: ParamKey, enabled: boolean) => {
207
+ const namePath = PARAM_NAME_MAP[key];
208
+ let newValue: number | undefined;
209
+
210
+ if (!enabled) {
211
+ const currentValue = form.getFieldValue(namePath);
212
+ if (typeof currentValue === 'number') {
213
+ lastValuesRef.current[key] = currentValue;
214
+ }
215
+ newValue = undefined;
216
+ form.setFieldValue(namePath, undefined);
217
+ } else {
218
+ const fallback = lastValuesRef.current[key];
219
+ const nextValue = typeof fallback === 'number' ? fallback : PARAM_DEFAULTS[key];
220
+ lastValuesRef.current[key] = nextValue;
221
+ newValue = nextValue;
222
+ form.setFieldValue(namePath, nextValue);
223
+ }
224
+
225
+ // 立即保存变更 - 手动构造配置对象确保使用最新值
226
+ setUpdating(true);
227
+ const currentValues = form.getFieldsValue();
228
+ const prevParams = (currentValues.params ?? {}) as Record<ParamKey, number | undefined>;
229
+ const currentParams: Record<ParamKey, number | undefined> = { ...prevParams };
230
+
231
+ if (newValue === undefined) {
232
+ // 显式删除该属性,而不是设置为 undefined
233
+ // 这样可以确保 Form 表单状态同步
234
+ delete currentParams[key];
235
+ // 使用 null 作为禁用标记(数据库会保留 null,前端据此判断复选框状态)
236
+ currentParams[key] = null as any;
237
+ } else {
238
+ currentParams[key] = newValue;
239
+ }
240
+
241
+ const updatedConfig = {
242
+ ...currentValues,
243
+ params: currentParams,
244
+ };
245
+
246
+ await updateAgentConfig(updatedConfig);
247
+ setUpdating(false);
52
248
  },
53
- {
54
- children: <PresencePenalty />,
55
- label: (
56
- <Flexbox gap={8} horizontal>
57
- {t('settingModel.presencePenalty.title')}
58
- <InfoTooltip title={t('settingModel.presencePenalty.desc')} />
59
- </Flexbox>
249
+ [form, setUpdating, updateAgentConfig],
250
+ );
251
+
252
+ // 使用 useMemo 确保防抖函数只创建一次
253
+ const handleValuesChange = useCallback(
254
+ debounce(async (values) => {
255
+ setUpdating(true);
256
+ await updateAgentConfig(values);
257
+ setUpdating(false);
258
+ }, 500),
259
+ [updateAgentConfig, setUpdating],
260
+ );
261
+
262
+ const baseItems: FormItemProps[] = (Object.keys(PARAM_CONFIG) as ParamKey[]).map((key) => {
263
+ const meta = PARAM_CONFIG[key];
264
+ const Component = meta.Component;
265
+ const enabled = enabledMap[key];
266
+
267
+ return {
268
+ children: (
269
+ <ParamControlWrapper
270
+ Component={Component}
271
+ checked={enabled}
272
+ disabled={!enabled}
273
+ onToggle={(checked) => handleToggle(key, checked)}
274
+ styles={styles}
275
+ />
60
276
  ),
61
- name: ['params', 'presence_penalty'],
62
- tag: 'presence_penalty',
63
- },
64
- {
65
- children: <FrequencyPenalty />,
66
277
  label: (
67
- <Flexbox gap={8} horizontal>
68
- {t('settingModel.frequencyPenalty.title')}
69
- <InfoTooltip title={t('settingModel.frequencyPenalty.desc')} />
278
+ <Flexbox align={'center'} className={styles.label} gap={8} horizontal>
279
+ {t(meta.labelKey)}
280
+ <InfoTooltip title={t(meta.descKey)} />
70
281
  </Flexbox>
71
282
  ),
72
- name: ['params', 'frequency_penalty'],
73
- tag: 'frequency_penalty',
74
- },
75
- ];
283
+ name: PARAM_NAME_MAP[key],
284
+ tag: meta.tag,
285
+ } satisfies FormItemProps;
286
+ });
76
287
 
77
288
  return (
78
289
  <Form
290
+ form={form}
79
291
  initialValues={config}
80
- itemMinWidth={200}
292
+ itemMinWidth={220}
81
293
  items={
82
294
  mobile
83
- ? items
84
- : items.map(({ tag, ...item }) => ({ ...item, desc: <Tag size={'small'}>{tag}</Tag> }))
295
+ ? baseItems
296
+ : baseItems.map(({ tag, ...item }) => ({
297
+ ...item,
298
+ desc: <Tag size={'small'}>{tag}</Tag>,
299
+ }))
85
300
  }
86
301
  itemsType={'flat'}
87
- onValuesChange={debounce(async (values) => {
88
- setUpdating(true);
89
- await updateAgentConfig(values);
90
- setUpdating(false);
91
- }, 500)}
302
+ onValuesChange={handleValuesChange}
92
303
  styles={{
93
304
  group: {
94
305
  background: 'transparent',
@@ -5,16 +5,20 @@ import { memo } from 'react';
5
5
  import { Flexbox } from 'react-layout-kit';
6
6
 
7
7
  interface FrequencyPenaltyProps {
8
+ disabled?: boolean;
8
9
  onChange?: (value: number) => void;
9
10
  value?: number;
10
11
  }
11
12
 
12
- const FrequencyPenalty = memo<FrequencyPenaltyProps>(({ value, onChange }) => {
13
+ const FrequencyPenalty = memo<FrequencyPenaltyProps>(({ value, onChange, disabled }) => {
13
14
  const theme = useTheme();
14
15
 
15
16
  return (
16
- <Flexbox style={{ paddingInlineStart: 8 }}>
17
+ <Flexbox style={{ width: '100%' }}>
17
18
  <SliderWithInput
19
+ changeOnWheel
20
+ controls={false}
21
+ disabled={disabled}
18
22
  marks={{
19
23
  '-2': (
20
24
  <Icon icon={FileIcon} size={'small'} style={{ color: theme.colorTextQuaternary }} />
@@ -29,9 +33,10 @@ const FrequencyPenalty = memo<FrequencyPenaltyProps>(({ value, onChange }) => {
29
33
  onChange={onChange}
30
34
  size={'small'}
31
35
  step={0.1}
36
+ style={{ height: 42 }}
32
37
  styles={{
33
38
  input: {
34
- maxWidth: 64,
39
+ maxWidth: 43,
35
40
  },
36
41
  }}
37
42
  value={value}
@@ -5,16 +5,20 @@ import { memo } from 'react';
5
5
  import { Flexbox } from 'react-layout-kit';
6
6
 
7
7
  interface PresencePenaltyProps {
8
+ disabled?: boolean;
8
9
  onChange?: (value: number) => void;
9
10
  value?: number;
10
11
  }
11
12
 
12
- const PresencePenalty = memo<PresencePenaltyProps>(({ value, onChange }) => {
13
+ const PresencePenalty = memo<PresencePenaltyProps>(({ value, onChange, disabled }) => {
13
14
  const theme = useTheme();
14
15
 
15
16
  return (
16
- <Flexbox style={{ paddingInlineStart: 8 }}>
17
+ <Flexbox style={{ width: '100%' }}>
17
18
  <SliderWithInput
19
+ changeOnWheel
20
+ controls={false}
21
+ disabled={disabled}
18
22
  marks={{
19
23
  '-2': (
20
24
  <Icon icon={RepeatIcon} size={'small'} style={{ color: theme.colorTextQuaternary }} />
@@ -27,9 +31,10 @@ const PresencePenalty = memo<PresencePenaltyProps>(({ value, onChange }) => {
27
31
  onChange={onChange}
28
32
  size={'small'}
29
33
  step={0.1}
34
+ style={{ height: 42 }}
30
35
  styles={{
31
36
  input: {
32
- maxWidth: 64,
37
+ maxWidth: 43,
33
38
  },
34
39
  }}
35
40
  value={value}
@@ -41,16 +41,19 @@ const Warning = memo(() => {
41
41
  });
42
42
 
43
43
  interface TemperatureProps {
44
+ disabled?: boolean;
44
45
  onChange?: (value: number) => void;
45
46
  value?: number;
46
47
  }
47
48
 
48
- const Temperature = memo<TemperatureProps>(({ value, onChange }) => {
49
+ const Temperature = memo<TemperatureProps>(({ value, onChange, disabled }) => {
49
50
  const theme = useTheme();
50
51
  return (
51
- <Flexbox gap={4} style={{ paddingInlineStart: 8 }}>
52
+ <Flexbox gap={4} style={{ width: '100%' }}>
52
53
  <SliderWithInput
54
+ changeOnWheel
53
55
  controls={false}
56
+ disabled={disabled}
54
57
  marks={{
55
58
  0: <Icon icon={Sparkle} size={'small'} style={{ color: theme.colorTextQuaternary }} />,
56
59
  1: <div />,
@@ -60,15 +63,15 @@ const Temperature = memo<TemperatureProps>(({ value, onChange }) => {
60
63
  onChange={onChange}
61
64
  size={'small'}
62
65
  step={0.1}
63
- style={{ height: 48 }}
66
+ style={{ height: 42 }}
64
67
  styles={{
65
68
  input: {
66
- maxWidth: 64,
69
+ maxWidth: 43,
67
70
  },
68
71
  }}
69
72
  value={value}
70
73
  />
71
- <Warning />
74
+ {!disabled && <Warning />}
72
75
  </Flexbox>
73
76
  );
74
77
  });
@@ -5,16 +5,20 @@ import { memo } from 'react';
5
5
  import { Flexbox } from 'react-layout-kit';
6
6
 
7
7
  interface TopPProps {
8
+ disabled?: boolean;
8
9
  onChange?: (value: number) => void;
9
10
  value?: number;
10
11
  }
11
12
 
12
- const TopP = memo<TopPProps>(({ value, onChange }) => {
13
+ const TopP = memo<TopPProps>(({ value, onChange, disabled }) => {
13
14
  const theme = useTheme();
14
15
 
15
16
  return (
16
- <Flexbox style={{ paddingInlineStart: 8 }}>
17
+ <Flexbox style={{ width: '100%' }}>
17
18
  <SliderWithInput
19
+ changeOnWheel
20
+ controls={false}
21
+ disabled={disabled}
18
22
  marks={{
19
23
  0: (
20
24
  <Icon
@@ -31,9 +35,10 @@ const TopP = memo<TopPProps>(({ value, onChange }) => {
31
35
  onChange={onChange}
32
36
  size={'small'}
33
37
  step={0.1}
38
+ style={{ height: 42 }}
34
39
  styles={{
35
40
  input: {
36
- maxWidth: 64,
41
+ maxWidth: 43,
37
42
  },
38
43
  }}
39
44
  value={value}
@@ -1,7 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { QueryClient } from '@tanstack/query-core';
4
- import { QueryClientProvider } from '@tanstack/react-query';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
4
  import React, { PropsWithChildren, useState } from 'react';
6
5
 
7
6
  import { lambdaQuery, lambdaQueryClient } from '@/libs/trpc/client';
@@ -274,6 +274,12 @@ class ChatService {
274
274
  { ...res, apiMode, model },
275
275
  );
276
276
 
277
+ // Convert null to undefined for model params to prevent sending null values to API
278
+ if (payload.temperature === null) payload.temperature = undefined;
279
+ if (payload.top_p === null) payload.top_p = undefined;
280
+ if (payload.presence_penalty === null) payload.presence_penalty = undefined;
281
+ if (payload.frequency_penalty === null) payload.frequency_penalty = undefined;
282
+
277
283
  const sdkType = resolveRuntimeProvider(provider);
278
284
 
279
285
  /**
@@ -1,73 +0,0 @@
1
- import { expect, test } from '@playwright/test';
2
-
3
- // 覆盖核心可访问路径(含重定向来源)
4
- const baseRoutes: string[] = [
5
- '/',
6
- '/chat',
7
- '/discover',
8
- '/image',
9
- '/files',
10
- '/repos', // next.config.ts -> /files
11
- '/changelog',
12
- ];
13
-
14
- // settings 路由改为通过 query 参数控制 active tab
15
- // 参考 SettingsTabs: about, agent, common, hotkey, llm, provider, proxy, storage, system-agent, tts
16
- const settingsTabs = [
17
- 'common',
18
- 'llm',
19
- 'provider',
20
- 'about',
21
- 'hotkey',
22
- 'proxy',
23
- 'storage',
24
- 'tts',
25
- 'system-agent',
26
- 'agent',
27
- ];
28
-
29
- const routes: string[] = [...baseRoutes, ...settingsTabs.map((key) => `/settings?active=${key}`)];
30
-
31
- // CI 环境下跳过容易不稳定或受特性开关影响的路由
32
- const ciSkipPaths = new Set<string>([
33
- '/image',
34
- '/changelog',
35
- '/settings?active=common',
36
- '/settings?active=llm',
37
- ]);
38
-
39
- // @ts-ignore
40
- async function assertNoPageErrors(page: Parameters<typeof test>[0]['page']) {
41
- const pageErrors: Error[] = [];
42
- const consoleErrors: string[] = [];
43
-
44
- page.on('pageerror', (err: Error) => pageErrors.push(err));
45
- page.on('console', (msg: any) => {
46
- if (msg.type() === 'error') consoleErrors.push(msg.text());
47
- });
48
-
49
- // 仅校验页面级错误,忽略控制台 error 以提升稳定性
50
- expect
51
- .soft(pageErrors, `page errors: ${pageErrors.map((e) => e.message).join('\n')}`)
52
- .toHaveLength(0);
53
- }
54
-
55
- test.describe('Smoke: core routes', () => {
56
- for (const path of routes) {
57
- test(`should open ${path} without error`, async ({ page }) => {
58
- if (process.env.CI && ciSkipPaths.has(path)) test.skip(true, 'skip flaky route on CI');
59
- const response = await page.goto(path, { waitUntil: 'commit' });
60
- // 2xx 或 3xx 视为可接受(允许中间件/重定向)
61
- const status = response?.status() ?? 0;
62
- expect(status, `unexpected status for ${path}: ${status}`).toBeLessThan(400);
63
-
64
- // 一般错误标题防御
65
- await expect(page).not.toHaveTitle(/not found|error/i);
66
-
67
- // body 可见
68
- await expect(page.locator('body')).toBeVisible();
69
-
70
- await assertNoPageErrors(page);
71
- });
72
- }
73
- });
@@ -1,35 +0,0 @@
1
- import { defineConfig, devices } from '@playwright/test';
2
-
3
- const PORT = process.env.PORT ? Number(process.env.PORT) : 3010;
4
-
5
- export default defineConfig({
6
- expect: { timeout: 10_000 },
7
- fullyParallel: true,
8
- projects: [
9
- {
10
- name: 'chromium',
11
- use: { ...devices['Desktop Chrome'] },
12
- },
13
- ],
14
- reporter: 'list',
15
- retries: 0,
16
- testDir: './e2e',
17
- timeout: 1_200_000,
18
- use: {
19
- baseURL: `http://localhost:${PORT}`,
20
- trace: 'on-first-retry',
21
- },
22
- webServer: {
23
- command: 'npm run dev',
24
- env: {
25
- ENABLE_AUTH_PROTECTION: '0',
26
- ENABLE_OIDC: '0',
27
- NEXT_PUBLIC_ENABLE_CLERK_AUTH: '0',
28
- NEXT_PUBLIC_ENABLE_NEXT_AUTH: '0',
29
- NODE_OPTIONS: '--max-old-space-size=6144',
30
- },
31
- reuseExistingServer: true,
32
- timeout: 120_000,
33
- url: `http://localhost:${PORT}/chat`,
34
- },
35
- });