refactor(e2e): organize fixtures by feature

This commit is contained in:
2026-07-16 11:49:58 +08:00
parent 764bb5a862
commit e730799aa5
28 changed files with 521 additions and 1335 deletions
-112
View File
@@ -1,112 +0,0 @@
# 聊天数量限制测试
## 功能点
验证游客在聊天页连续发送消息后,是否会在限定次数内触发聊天数量限制;触发后是否能够继续完成登录、订阅、支付和返回聊天页的完整链路。
## 测试目标
1. 游客进入聊天页后,连续聊天最多 `51` 条。
2. 若在 `51` 条以内触发聊天限制,则继续验证后续跳转和支付链路。
3. 若发送到第 `51` 条后仍未出现目标受限界面,则判定本功能点测试失败。
## 测试网址
- 统一测试网址请查看:[test-site.md](./test-site.md)
## 目标受限界面
出现以下现象时,视为命中目标界面:
1. 聊天输入框不可见或不可继续输入。
2. 页面出现跳转按钮,用于继续前往登录或订阅流程。
## 测试数据
### 聊天数据
- 消息发送上限:`51`
- 建议消息模板:
- `limit test 01`
- `limit test 02`
- `limit test 03`
- ...
- `limit test 51`
### 登录流程引用
- 登录相关操作请参考:[email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”
## 操作流程
### 阶段一:进入聊天页并验证数量限制
1. 打开统一测试网址(见 [test-site.md](./test-site.md)
2. 在首页点击 `Skip`
3. 进入聊天界面
4. 以顺序消息数据持续发送聊天内容,从 `limit test 01` 开始,最多发送到 `limit test 51`
5. 在发送过程中持续观察页面状态
### 阶段二:数量限制判定
1. 若在第 `1` 至第 `51` 条消息之间出现目标受限界面:
- 继续执行后续登录与订阅流程
2. 若发送完第 `51` 条消息后仍未出现目标受限界面:
- 直接判定该功能点测试失败
- 停止后续登录、订阅、支付步骤
## 触发限制后的后续流程
### 阶段三:登录流程
1. 点击受限界面中的跳转按钮
2. 进入登录界面
3. 登录相关操作不在本文件内重复展开
4. 直接参考 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”
5. 登录成功后继续执行后续订阅流程
### 阶段四:订阅支付与回跳验证
1. 登录成功后,确认跳转到订阅界面
2. 点击 `Pay And Activate` 按钮
3. 等待支付成功
4. 点击 `OK` 按钮
5. 等待页面跳转回聊天界面
## 预期结果
### 数量限制预期
1. 游客在连续聊天过程中,会在第 `51` 条消息以内命中聊天数量限制。
2. 命中限制后,聊天输入框不可继续使用。
3. 页面出现用于继续流程的跳转按钮。
### 登录与支付链路预期
1. 点击跳转按钮后,可进入登录界面。
2. 按照邮箱登录测试流程完成登录后,可进入订阅界面。
3. 订阅和 Stripe 流程可正常操作。
4. 支付成功后,点击 `OK` 可返回聊天界面。
## 失败判定
满足以下任一条件即判定本功能点失败:
1. 发送到第 `51` 条消息后,仍未出现目标受限界面。
2. 触发限制后,跳转按钮不存在或无法点击。
3. 无法进入登录界面。
4. 按照邮箱登录测试流程执行后登录失败。
5. 无法进入订阅页。
6. Stripe 流程无法继续。
7. 支付成功后无法返回聊天页。
## 执行备注
1. 若执行过程中出现验证码,需要人工确认后再继续。
2. 若支付步骤会产生真实外部副作用,应在执行支付提交前再次确认。
3. 建议在正式执行时逐条记录:
- 实际发送到第几条时触发限制
- 跳转按钮文案
- 登录流程参考文件及登录跳转结果
- 支付结果
- 最终是否成功返回聊天页
@@ -1,87 +0,0 @@
# Other Sign In Options 邮箱登录测试
## 功能点
验证用户从 `splash` 页进入聊天页后,通过顶部 `Sign up to unlock more features` 进入登录页,再通过 `Other Sign In Options` 选择邮箱登录,最终返回聊天界面的流程是否正常。
## 测试目标
1.`splash` 页点击 `Skip` 进入聊天界面。
2. 从聊天页顶部入口进入登录界面。
3. 点击 `Other Sign In Options` 弹出选择弹窗。
4. 点击 `Email Sign In` 后等待界面刷新到邮箱登录表单。
5. 点击 `Login` 按钮并等待跳转回聊天界面。
## 测试网址
- 统一测试网址请查看:[test-site.md](./test-site.md)
## 测试数据
- 测试网址引用:见 [test-site.md](./test-site.md)
## 操作流程
### 阶段一:进入聊天页
1. 打开统一测试网址(见 [test-site.md](./test-site.md)
2. 进入 `splash` 界面
3. 点击 `Skip` 按钮
4. 等待页面跳转到聊天界面
### 阶段二:进入登录页
1. 在聊天页顶部点击 `Sign up to unlock more features`
2. 等待页面跳转到登录界面
### 阶段三:进入邮箱登录方式
1. 在登录界面点击 `Other Sign In Options`
2. 等待选择弹窗出现
3. 在弹窗中点击 `Email Sign In`
4. 等待界面刷新为邮箱登录界面
### 阶段四:提交登录
1. 点击 `Login` 按钮
2. 等待登录完成
### 阶段五:验证跳转结果
1. 确认页面从登录流程返回站内页面
2. 等待页面跳转回聊天界面
3. 确认聊天界面展示正常
## 预期结果
1. 点击 `Skip` 后可正常从 `splash` 页进入聊天界面。
2. 点击顶部 `Sign up to unlock more features` 后可进入登录界面。
3. 点击 `Other Sign In Options` 后可弹出选择弹窗。
4. 点击 `Email Sign In` 后可切换到邮箱登录界面。
5. 点击 `Login` 后可成功完成登录。
6. 登录成功后可正常跳转回聊天界面。
## 失败判定
满足以下任一条件即判定本功能点失败:
1. `Skip` 按钮不存在或无法点击。
2. 无法从 `splash` 页进入聊天界面。
3. 顶部 `Sign up to unlock more features` 按钮不存在或无法点击。
4. 无法进入登录界面。
5. `Other Sign In Options` 按钮不存在或无法点击。
6. 未弹出选择弹窗。
7. `Email Sign In` 按钮不存在或无法点击。
8. 无法切换到邮箱登录界面。
9. 点击 `Login` 后登录失败。
10. 登录完成后未返回聊天界面。
## 执行备注
1. 如果登录后存在中间态页面或短暂回跳,应记录实际路径和停留页面。
2. 正式执行时建议记录:
- 是否成功从 `splash` 页进入聊天页
- 是否成功进入登录页
- `Other Sign In Options` 弹窗是否正常出现
- 是否成功切换到邮箱登录界面
- 点击 `Login` 后是否成功跳回聊天页
-63
View File
@@ -1,63 +0,0 @@
# 侧边栏退出登录测试
## 功能点
验证用户从聊天界面进入侧边栏后,点击 `Logout` 按钮是否能够成功退出登录,并跳转回 `splash` 界面。
## 测试目标
1. 从聊天界面进入侧边栏界面。
2. 在侧边栏中点击 `Logout` 按钮。
3. 验证退出登录后页面是否跳转回 `splash` 界面。
## 测试网址
- 统一测试网址请查看:[test-site.md](./test-site.md)
## 测试数据
- 测试网址引用:见 [test-site.md](./test-site.md)
## 操作流程
### 阶段一:进入聊天界面
1. 打开统一测试网址(见 [test-site.md](./test-site.md)
2. 进入聊天界面
### 阶段二:进入侧边栏
1. 点击屏幕左上角菜单按钮
2. 等待页面跳转到侧边栏界面
### 阶段三:执行退出登录
1. 在侧边栏界面点击 `Logout` 按钮
2. 等待退出登录完成
### 阶段四:验证跳转结果
1. 确认页面跳转到 `splash` 界面
2. 确认 `splash` 页核心入口正常展示
## 预期结果
1. 用户可以从聊天界面正常进入侧边栏界面。
2. 侧边栏中的 `Logout` 按钮可以正常点击。
3. 点击 `Logout` 后可以成功退出登录。
4. 退出登录后页面跳转回 `splash` 界面。
## 失败判定
满足以下任一条件即判定本功能点失败:
1. 无法从聊天界面进入侧边栏界面。
2. 左上角菜单按钮不存在或无法点击。
3. `Logout` 按钮不存在或无法点击。
4. 点击 `Logout` 后未成功退出登录。
5. 退出登录后未跳转到 `splash` 界面。
## 执行备注
1. 如退出登录后存在短暂中间态页面,应记录中间跳转路径。
2. 若页面跳转到 `splash` 后仍保留登录态相关内容,应进一步确认是否真正完成登出。
-114
View File
@@ -1,114 +0,0 @@
# 付费图片测试
## 功能点
验证游客在聊天页发送图片请求后,是否能够触发 AI 回复付费图片消息;点击图片进入大图界面后,是否能够通过登录、订阅、支付流程解锁,并在支付成功后返回图片大图界面。
## 测试目标
1.`splash` 页点击 `Skip` 进入聊天界面。
2. 在聊天页发送文字消息 `给我发图片`
3. 验证 AI 回复付费图片消息。
4. 点击付费图片,跳转至图片大图界面。
5. 点击 `Unlock high-definition large image` 按钮进入登录流程。
6. 按照邮箱登录流程完成登录。
7. 登录成功后进入订阅界面,完成支付。
8. 支付成功后点击 `OK`,验证页面返回图片大图界面。
## 测试网址
- 统一测试网址请查看:[test-site.md](./test-site.md)
## 测试数据
### 聊天数据
- 消息内容:`给我发图片`
### 登录流程引用
- 登录相关操作请参考:[email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”和“阶段四:提交登录”。
## 操作流程
### 阶段一:进入聊天页
1. 打开统一测试网址(见 [test-site.md](./test-site.md)
2. 进入 `splash` 界面
3. 点击 `Skip` 按钮
4. 等待页面跳转到聊天界面
5. 确认聊天输入框展示正常
### 阶段二:触发付费图片消息
1. 在聊天输入框输入 `给我发图片`
2. 点击发送按钮
3. 等待 AI 回复
4. 确认聊天区域出现付费图片消息
### 阶段三:进入图片大图界面
1. 点击聊天区域中的付费图片
2. 等待页面跳转到图片大图界面
3. 确认图片大图界面展示正常
4. 确认页面存在 `Unlock high-definition large image` 按钮
### 阶段四:进入登录流程
1. 点击 `Unlock high-definition large image` 按钮
2. 等待页面跳转到登录界面
3. 按照 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”执行邮箱登录入口切换
4. 按照 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段四:提交登录”点击 `Login` 并等待登录完成
### 阶段五:订阅支付与回跳验证
1. 登录成功后,确认页面跳转到订阅界面
2. 点击 `Pay And Activate` 按钮
3. 等待支付成功
4. 点击 `OK` 按钮
5. 等待页面跳转回图片大图界面
## 预期结果
### 付费图片预期
1. 点击 `Skip` 后可正常进入聊天界面。
2. 发送 `给我发图片` 后,AI 可正常回复付费图片消息。
3. 点击付费图片后,可进入图片大图界面。
4. 图片大图界面展示 `Unlock high-definition large image` 按钮。
### 登录与支付链路预期
1. 点击 `Unlock high-definition large image` 后可进入登录界面。
2. 按照邮箱登录流程可成功完成登录。
3. 登录成功后可进入订阅界面。
4. 点击 `Pay And Activate` 后可正常完成支付流程。
5. 支付成功后点击 `OK` 可返回图片大图界面。
## 失败判定
满足以下任一条件即判定本功能点失败:
1. `Skip` 按钮不存在或无法点击。
2. 无法从 `splash` 页进入聊天界面。
3. 无法发送 `给我发图片` 消息。
4. AI 未回复付费图片消息。
5. 付费图片无法点击或点击后未进入图片大图界面。
6. 图片大图界面未展示 `Unlock high-definition large image` 按钮。
7. 点击解锁按钮后未进入登录界面。
8. 按照邮箱登录流程执行后登录失败。
9. 登录成功后未进入订阅界面。
10. 点击 `Pay And Activate` 后支付流程失败。
11. 支付成功后点击 `OK` 未返回图片大图界面。
## 执行备注
1. 若执行过程中出现验证码,需要人工确认后再继续。
2. 若支付步骤会产生真实外部副作用,应在执行支付提交前再次确认。
3. 正式执行时建议记录:
- 是否成功触发付费图片消息
- 图片大图界面的实际 URL
- 解锁按钮文案
- 登录流程参考文件及登录跳转结果
- 支付结果
- 最终是否成功返回图片大图界面
-105
View File
@@ -1,105 +0,0 @@
# 付费语音测试
## 功能点
验证游客在聊天页发送语音请求后,是否能够触发 AI 回复付费语音消息;点击解锁语音消息后,是否能够通过登录、订阅、支付流程解锁,并在支付成功后返回聊天界面。
## 测试目标
1.`splash` 页点击 `Skip` 进入聊天界面。
2. 在聊天页发送文字消息 `给我发语音`
3. 验证 AI 回复付费语音消息。
4. 点击 `Unlock voice message` 按钮进入登录流程。
5. 按照邮箱登录流程完成登录。
6. 登录成功后进入订阅界面,完成支付。
7. 支付成功后点击 `OK`,验证页面返回聊天界面。
## 测试网址
- 统一测试网址请查看:[test-site.md](./test-site.md)
## 测试数据
### 聊天数据
- 消息内容:`给我发语音`
### 登录流程引用
- 登录相关操作请参考:[email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”和“阶段四:提交登录”。
## 操作流程
### 阶段一:进入聊天页
1. 打开统一测试网址(见 [test-site.md](./test-site.md)
2. 进入 `splash` 界面
3. 点击 `Skip` 按钮
4. 等待页面跳转到聊天界面
5. 确认聊天输入框展示正常
### 阶段二:触发付费语音消息
1. 在聊天输入框输入 `给我发语音`
2. 点击发送按钮
3. 等待 AI 回复
4. 确认聊天区域出现付费语音消息
5. 确认付费语音消息展示 `Unlock voice message` 按钮
### 阶段三:进入登录流程
1. 点击 `Unlock voice message` 按钮
2. 等待页面跳转到登录界面
3. 按照 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段三:进入邮箱登录方式”执行邮箱登录入口切换
4. 按照 [email-login-from-other-options.md](./email-login-from-other-options.md) 中的“阶段四:提交登录”点击 `Login` 并等待登录完成
### 阶段四:订阅支付与回跳验证
1. 登录成功后,确认页面跳转到订阅界面
2. 点击 `Pay And Activate` 按钮
3. 等待支付成功
4. 点击 `OK` 按钮
5. 等待页面跳转回聊天界面
6. 确认聊天界面展示正常
## 预期结果
### 付费语音预期
1. 点击 `Skip` 后可正常进入聊天界面。
2. 发送 `给我发语音` 后,AI 可正常回复付费语音消息。
3. 付费语音消息展示 `Unlock voice message` 按钮。
4. 点击 `Unlock voice message` 后可进入登录界面。
### 登录与支付链路预期
1. 按照邮箱登录流程可成功完成登录。
2. 登录成功后可进入订阅界面。
3. 点击 `Pay And Activate` 后可正常完成支付流程。
4. 支付成功后点击 `OK` 可返回聊天界面。
## 失败判定
满足以下任一条件即判定本功能点失败:
1. `Skip` 按钮不存在或无法点击。
2. 无法从 `splash` 页进入聊天界面。
3. 无法发送 `给我发语音` 消息。
4. AI 未回复付费语音消息。
5. 付费语音消息未展示 `Unlock voice message` 按钮。
6. 点击 `Unlock voice message` 后未进入登录界面。
7. 按照邮箱登录流程执行后登录失败。
8. 登录成功后未进入订阅界面。
9. 点击 `Pay And Activate` 后支付流程失败。
10. 支付成功后点击 `OK` 未返回聊天界面。
## 执行备注
1. 若执行过程中出现验证码,需要人工确认后再继续。
2. 若支付步骤会产生真实外部副作用,应在执行支付提交前再次确认。
3. 正式执行时建议记录:
- 是否成功触发付费语音消息
- `Unlock voice message` 按钮是否展示
- 登录流程参考文件及登录跳转结果
- 支付结果
- 最终是否成功返回聊天界面
-10
View File
@@ -1,10 +0,0 @@
# 统一测试网址
## 测试网址
- `https://frontend-test.banlv-ai.com`
## 说明
1. `docs/manual-tests` 目录下的测试流程默认都使用这个统一测试网址。
2. 若测试环境地址发生变化,只需更新本文件。
+20 -199
View File
@@ -1,31 +1,11 @@
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { import { registerAuthMocks } from "./api/auth";
apiEnvelope, import { registerChatMocks } from "./api/chat";
chatSendResponse, import { registerPaymentMocks } from "./api/payment";
createPaymentOrderResponse, import { registerUserMocks } from "./api/user";
createChatSendResponse,
createPaidImageHistoryResponse,
createWeeklyLimitChatSendResponse,
e2eEmailUser,
emptyChatHistoryResponse,
emailLoginResponse,
guestLoginResponse,
insufficientCreditsUnlockImageResponse,
insufficientCreditsUnlockVoiceResponse,
paidImageChatSendResponse,
paidImageUnlockHistoryResponse,
paidVoiceHistoryResponse,
paidPaymentOrderStatusResponse,
paymentPlansResponse,
tipPaymentPlansResponse,
refreshedEmailLoginResponse,
refreshedGuestLoginResponse,
userEntitlementsResponse,
vipStatusResponse,
} from "./test-data";
interface MockCoreApisOptions { export interface MockCoreApisOptions {
chatLimitTriggerAt?: number; chatLimitTriggerAt?: number;
chatSendTokenRefreshFlow?: boolean; chatSendTokenRefreshFlow?: boolean;
paidImageFlow?: boolean; paidImageFlow?: boolean;
@@ -33,180 +13,21 @@ interface MockCoreApisOptions {
paidVoiceInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean;
} }
export async function mockCoreApis( export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) {
page: Page, const chatOptions = {
options: MockCoreApisOptions = {}, chatLimitTriggerAt: options.chatLimitTriggerAt,
): Promise<void> { chatSendTokenRefreshFlow: options.chatSendTokenRefreshFlow ?? false,
const { paidImageFlow: options.paidImageFlow ?? false,
chatLimitTriggerAt, paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false,
chatSendTokenRefreshFlow = false, paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false,
paidImageFlow = false, };
paidImageInsufficientCreditsFlow = false, const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false };
paidVoiceInsufficientCreditsFlow = false,
} = options;
let sendCount = 0;
let hasExpiredChatSend = false;
let paidImageRequested = false;
let paidImageUnlocked = false;
await page.route("**/api/auth/guest", async (route) => { await registerAuthMocks(page, {
const response = chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow,
chatSendTokenRefreshFlow && hasExpiredChatSend isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
? refreshedGuestLoginResponse
: guestLoginResponse;
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/auth/login", async (route) => {
if (paidImageFlow) {
paidImageRequested = true;
}
await route.fulfill({ json: apiEnvelope(emailLoginResponse) });
});
await page.route("**/api/auth/refresh", async (route) => {
await route.fulfill({ json: apiEnvelope(refreshedEmailLoginResponse) });
});
await page.route("**/api/auth/logout", async (route) => {
await route.fulfill({ json: apiEnvelope(null) });
});
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({ json: apiEnvelope(e2eEmailUser) });
});
await page.route("**/api/user/entitlements", async (route) => {
await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) });
});
await page.route("**/api/chat/history**", async (route) => {
const response =
paidVoiceInsufficientCreditsFlow
? paidVoiceHistoryResponse
: paidImageInsufficientCreditsFlow
? createPaidImageHistoryResponse(false)
: paidImageFlow && paidImageRequested
? createPaidImageHistoryResponse(paidImageUnlocked)
: emptyChatHistoryResponse;
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/chat/send", async (route) => {
sendCount += 1;
const requestBody = route.request().postDataJSON() as
| { message?: unknown }
| undefined;
const message =
typeof requestBody?.message === "string" ? requestBody.message : "";
if (chatSendTokenRefreshFlow && !hasExpiredChatSend) {
hasExpiredChatSend = true;
await route.fulfill({
status: 401,
json: {
message: "expired",
},
});
return;
}
const response =
paidImageFlow && message.includes("给我发图片")
? paidImageChatSendResponse
: chatLimitTriggerAt != null && sendCount >= chatLimitTriggerAt
? createWeeklyLimitChatSendResponse(sendCount, chatLimitTriggerAt)
: chatLimitTriggerAt != null
? createChatSendResponse(sendCount)
: chatSendResponse;
if (paidImageFlow && message.includes("给我发图片")) {
paidImageRequested = true;
paidImageUnlocked = false;
}
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/chat/unlock-history", async (route) => {
if (paidImageFlow && paidImageRequested) {
paidImageUnlocked = true;
}
const response =
paidImageFlow && paidImageRequested
? paidImageUnlockHistoryResponse
: {
unlocked: true,
reason: "no_locked_messages",
totalLocked: 0,
unlockedCount: 0,
privateCount: 0,
imageCount: 0,
voiceCount: 0,
requiredCredits: 0,
currentCredits: 0,
remainingCredits: 0,
shortfallCredits: 0,
costsByMessage: {},
messageIds: [],
};
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/chat/unlock-private", async (route) => {
const response =
paidVoiceInsufficientCreditsFlow
? insufficientCreditsUnlockVoiceResponse
: paidImageInsufficientCreditsFlow
? insufficientCreditsUnlockImageResponse
: paidImageFlow && paidImageRequested && !paidImageUnlocked
? insufficientCreditsUnlockImageResponse
: {
unlocked: true,
content: "Unlocked message",
reason: "ok",
creditBalance: 100,
creditsCharged: 0,
requiredCredits: 0,
shortfallCredits: 0,
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
};
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/payment/plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(paymentPlansResponse) });
});
await page.route("**/api/payment/tip-plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) });
});
await page.route("**/api/payment/create-order", async (route) => {
await route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) });
});
await page.route("**/api/payment/order-status**", async (route) => {
if (paidImageFlow && paidImageRequested) {
paidImageUnlocked = true;
}
await route.fulfill({ json: apiEnvelope(paidPaymentOrderStatusResponse) });
});
await page.route("**/api/payment/vip-status**", async (route) => {
await route.fulfill({ json: apiEnvelope(vipStatusResponse) });
}); });
await registerUserMocks(page);
await registerChatMocks(page, chatOptions, chatState);
await registerPaymentMocks(page);
} }
+25
View File
@@ -0,0 +1,25 @@
import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common";
import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth";
export interface AuthMockState {
chatSendTokenRefreshFlow: boolean;
isChatSendTokenExpired: () => boolean;
}
export async function registerAuthMocks(page: Page, state: AuthMockState) {
await page.route("**/api/auth/guest", async (route) => {
const response = state.chatSendTokenRefreshFlow && state.isChatSendTokenExpired() ? refreshedGuestLoginResponse : guestLoginResponse;
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/auth/login", async (route) => {
await route.fulfill({ json: apiEnvelope(emailLoginResponse) });
});
await page.route("**/api/auth/refresh", async (route) => {
await route.fulfill({ json: apiEnvelope(refreshedEmailLoginResponse) });
});
await page.route("**/api/auth/logout", async (route) => {
await route.fulfill({ json: apiEnvelope(null) });
});
}
+71
View File
@@ -0,0 +1,71 @@
import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common";
import { chatSendResponse, createChatSendResponse, createPaidImageHistoryResponse, createWeeklyLimitChatSendResponse, emptyChatHistoryResponse, insufficientCreditsUnlockImageResponse, insufficientCreditsUnlockVoiceResponse, paidImageChatSendResponse, paidImageUnlockHistoryResponse, paidVoiceHistoryResponse } from "../data/chat";
export interface ChatMockOptions {
chatLimitTriggerAt?: number;
chatSendTokenRefreshFlow: boolean;
paidImageFlow: boolean;
paidImageInsufficientCreditsFlow: boolean;
paidVoiceInsufficientCreditsFlow: boolean;
}
export interface ChatMockState {
hasExpiredChatSend: boolean;
paidImageRequested: boolean;
paidImageUnlocked: boolean;
}
export async function registerChatMocks(page: Page, options: ChatMockOptions, state: ChatMockState) {
let sendCount = 0;
await page.route("**/api/chat/history**", async (route) => {
const response = options.paidVoiceInsufficientCreditsFlow
? paidVoiceHistoryResponse
: options.paidImageInsufficientCreditsFlow
? createPaidImageHistoryResponse(false)
: options.paidImageFlow && state.paidImageRequested
? createPaidImageHistoryResponse(state.paidImageUnlocked)
: emptyChatHistoryResponse;
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/chat/send", async (route) => {
sendCount += 1;
const requestBody = route.request().postDataJSON() as { message?: unknown } | undefined;
const message = typeof requestBody?.message === "string" ? requestBody.message : "";
if (options.chatSendTokenRefreshFlow && !state.hasExpiredChatSend) {
state.hasExpiredChatSend = true;
await route.fulfill({ status: 401, json: { message: "expired" } });
return;
}
const response = options.paidImageFlow && message.includes("给我发图片")
? paidImageChatSendResponse
: options.chatLimitTriggerAt != null && sendCount >= options.chatLimitTriggerAt
? createWeeklyLimitChatSendResponse(sendCount, options.chatLimitTriggerAt)
: options.chatLimitTriggerAt != null
? createChatSendResponse(sendCount)
: chatSendResponse;
if (options.paidImageFlow && message.includes("给我发图片")) {
state.paidImageRequested = true;
state.paidImageUnlocked = false;
}
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/chat/unlock-history", async (route) => {
if (options.paidImageFlow && state.paidImageRequested) state.paidImageUnlocked = true;
const response = options.paidImageFlow && state.paidImageRequested ? paidImageUnlockHistoryResponse : { unlocked: true, reason: "no_locked_messages", totalLocked: 0, unlockedCount: 0, privateCount: 0, imageCount: 0, voiceCount: 0, requiredCredits: 0, currentCredits: 0, remainingCredits: 0, shortfallCredits: 0, costsByMessage: {}, messageIds: [] };
await route.fulfill({ json: apiEnvelope(response) });
});
await page.route("**/api/chat/unlock-private", async (route) => {
const response = options.paidVoiceInsufficientCreditsFlow
? insufficientCreditsUnlockVoiceResponse
: options.paidImageInsufficientCreditsFlow || options.paidImageFlow && state.paidImageRequested && !state.paidImageUnlocked
? insufficientCreditsUnlockImageResponse
: { unlocked: true, content: "Unlocked message", reason: "ok", creditBalance: 100, creditsCharged: 0, requiredCredits: 0, shortfallCredits: 0, lockDetail: { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } };
await route.fulfill({ json: apiEnvelope(response) });
});
}
+12
View File
@@ -0,0 +1,12 @@
import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common";
import { createPaymentOrderResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, tipPaymentPlansResponse, vipStatusResponse } from "../data/payment";
export async function registerPaymentMocks(page: Page) {
await page.route("**/api/payment/plans**", async (route) => route.fulfill({ json: apiEnvelope(paymentPlansResponse) }));
await page.route("**/api/payment/tip-plans**", async (route) => route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) }));
await page.route("**/api/payment/create-order", async (route) => route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }));
await page.route("**/api/payment/order-status**", async (route) => route.fulfill({ json: apiEnvelope(paidPaymentOrderStatusResponse) }));
await page.route("**/api/payment/vip-status", async (route) => route.fulfill({ json: apiEnvelope(vipStatusResponse) }));
}
+14
View File
@@ -0,0 +1,14 @@
import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common";
import { e2eEmailUser } from "../data/auth";
import { userEntitlementsResponse } from "../data/user";
export async function registerUserMocks(page: Page) {
await page.route("**/api/user/profile", async (route) => {
await route.fulfill({ json: apiEnvelope(e2eEmailUser) });
});
await page.route("**/api/user/entitlements", async (route) => {
await route.fulfill({ json: apiEnvelope(userEntitlementsResponse) });
});
}
+51
View File
@@ -0,0 +1,51 @@
export const e2eUser = {
id: "user_e2e",
username: "E2E User",
email: "e2e@example.com",
platform: "web",
intimacy: 42,
dolBalance: 0,
relationshipStage: "close_friend",
currentMood: "happy",
preferredLanguage: "en",
createdAt: "2026-06-25T00:00:00.000Z",
lastMessageAt: "",
avatarUrl: "",
loginProvider: "guest",
isGuest: true,
isVip: false,
voiceMinutesRemaining: 0,
};
export const e2eEmailUser = {
...e2eUser,
id: "user_e2e_email",
username: "E2E Email User",
email: "chanwillian0@gmail.com",
isGuest: false,
};
export const guestLoginResponse = {
token: "e2e-guest-token",
deviceId: "e2e-device-id",
userId: e2eUser.id,
isDeviceUser: true,
expiresIn: 2_592_000,
user: e2eUser,
};
export const refreshedGuestLoginResponse = {
...guestLoginResponse,
token: "e2e-refreshed-guest-token",
};
export const emailLoginResponse = {
token: "e2e-email-token",
refreshToken: "e2e-email-refresh-token",
user: e2eEmailUser,
};
export const refreshedEmailLoginResponse = {
token: "e2e-refreshed-email-token",
refreshToken: "e2e-refreshed-email-refresh-token",
};
+106
View File
@@ -0,0 +1,106 @@
export const emptyChatHistoryResponse = {
messages: [],
total: 0,
limit: 50,
offset: 0,
isVip: false,
privateFreeLimit: 0,
privateUsedToday: 0,
privateCanViewFree: false,
};
export const chatSendResponse = {
reply: "Hello from the E2E boyfriend.",
audioUrl: "",
messageId: "msg_e2e_reply_001",
isGuest: true,
timestamp: 1_782_356_425_363,
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
};
export function createChatSendResponse(sendCount: number) {
return {
...chatSendResponse,
reply: `Hello from the E2E boyfriend. Reply ${sendCount}.`,
messageId: `msg_e2e_reply_${String(sendCount).padStart(3, "0")}`,
timestamp: chatSendResponse.timestamp + sendCount,
};
}
export function createWeeklyLimitChatSendResponse(
usedMessageCount: number,
limit: number,
) {
return {
reply: "",
audioUrl: "",
messageId: "",
isGuest: true,
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
creditsCharged: 0,
requiredCredits: limit,
shortfallCredits: Math.max(0, limit - usedMessageCount),
timestamp: chatSendResponse.timestamp + usedMessageCount,
image: { type: null, url: null },
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "weekly_limit",
hint: "Free message limit reached.",
detail: { type: "weekly_msg_limit", usedThisWeek: usedMessageCount, limit },
},
};
}
export const paidImageMessageId = "msg_photo_paywall_001";
export const paidImageUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
export const paidImageChatSendResponse = {
reply: "I can show you that photo after you activate VIP.",
audioUrl: "",
messageId: paidImageMessageId,
isGuest: true,
timestamp: 1_782_180_725_000,
image: { type: "elio_schedule", url: paidImageUrl },
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Activate VIP to unlock the full photo.", detail: null },
};
export function createPaidImageHistoryResponse(unlocked: boolean) {
return {
messages: [{ role: "assistant", type: "image", content: unlocked ? "" : paidImageChatSendResponse.reply, id: paidImageMessageId, created_at: "2026-06-30T00:00:00.000Z", audioUrl: null, image: { type: "elio_schedule", url: paidImageUrl }, lockDetail: unlocked ? { locked: false, showContent: true, showUpgrade: false, reason: null, hint: null, detail: null } : paidImageChatSendResponse.lockDetail }],
total: 1, limit: 50, offset: 0, isVip: unlocked, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
};
}
export const paidImageUnlockHistoryResponse = {
unlocked: true, reason: "ok", totalLocked: 1, unlockedCount: 1, privateCount: 0, imageCount: 1, voiceCount: 0,
requiredCredits: 0, currentCredits: 0, remainingCredits: 0, shortfallCredits: 0,
costsByMessage: { [paidImageMessageId]: 0 }, messageIds: [paidImageMessageId],
};
export const paidVoiceMessageId = "msg_voice_paywall_001";
export const paidVoiceAudioUrl = "data:audio/mpeg;base64,//uQZAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAACcQCA";
export const paidVoiceHistoryResponse = {
messages: [{ role: "assistant", type: "voice", content: "", id: paidVoiceMessageId, created_at: "2026-07-01T00:00:00.000Z", audioUrl: paidVoiceAudioUrl, image: { type: null, url: null }, lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "voice_message", hint: "Unlock this voice message with credits.", detail: null } }],
total: 1, limit: 50, offset: 0, isVip: false, privateFreeLimit: 0, privateUsedToday: 0, privateCanViewFree: false,
};
export const insufficientCreditsUnlockVoiceResponse = {
unlocked: false, content: "", reason: "insufficient_balance", creditBalance: 3, creditsCharged: 0, requiredCredits: 20, shortfallCredits: 17,
lockDetail: paidVoiceHistoryResponse.messages[0].lockDetail,
};
export const insufficientCreditsUnlockImageResponse = {
unlocked: false, content: "", reason: "insufficient_balance", creditBalance: 3, creditsCharged: 0, requiredCredits: 30, shortfallCredits: 27,
lockDetail: { locked: true, showContent: true, showUpgrade: true, reason: "image", hint: "Unlock this high-definition image with credits.", detail: null },
};
+8
View File
@@ -0,0 +1,8 @@
export function apiEnvelope<T>(data: T) {
return {
code: 200,
message: "success",
success: true,
data,
};
}
+14
View File
@@ -0,0 +1,14 @@
export const paymentPlansResponse = { plans: [
{ planId: "vip_monthly", planName: "Monthly", orderType: "vip_monthly", amountCents: 999, originalAmountCents: 1999, dailyPriceCents: 33, currency: "USD", vipDays: 30, dolAmount: null, creditBalance: 300 },
{ planId: "vip_quarterly", planName: "Quarterly", orderType: "vip_quarterly", amountCents: 2499, originalAmountCents: 5999, dailyPriceCents: 28, currency: "USD", vipDays: 90, dolAmount: null, creditBalance: 1000 },
{ planId: "dol_voice_30", planName: "Voice Pack", orderType: "dol_voice", amountCents: 499, originalAmountCents: null, dailyPriceCents: null, currency: "USD", vipDays: null, dolAmount: 30, creditBalance: 30 },
] };
export const tipPaymentPlansResponse = { plans: [
{ planId: "tip_coffee_usd_4_99", planName: "Small Coffee", orderType: "tip", tipType: "coffee_small", description: "Buy Elio a small coffee", amountCents: 499, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 },
{ planId: "tip_coffee_usd_9_99", planName: "Medium Coffee", orderType: "tip", tipType: "coffee_medium", description: "Buy Elio a medium coffee", amountCents: 999, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 },
{ planId: "tip_coffee_usd_19_99", planName: "Large Coffee", orderType: "tip", tipType: "coffee_large", description: "Buy Elio a large coffee", amountCents: 1999, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0 },
] };
export const paymentOrderId = "order_e2e_vip_monthly";
export const createPaymentOrderResponse = { orderId: paymentOrderId, payParams: { provider: "stripe", clientSecret: "pi_e2e_secret_mock" } };
export const paidPaymentOrderStatusResponse = { orderId: paymentOrderId, status: "paid", orderType: "vip_monthly", planId: "vip_monthly" };
export const vipStatusResponse = { isVip: false, expiresAt: null };
+12
View File
@@ -0,0 +1,12 @@
import { e2eEmailUser } from "./auth";
export { e2eEmailUser } from "./auth";
export const userEntitlementsResponse = {
userId: e2eEmailUser.id,
isGuest: false,
isVip: false,
vipExpiresAt: null,
creditBalance: 0,
dolBalance: 0,
};
+10
View File
@@ -0,0 +1,10 @@
import { expect, type Page } from "@playwright/test";
export async function expectInsufficientCreditsDialog(page: Page) {
const dialog = page.getByRole("dialog", { name: "Not enough credits" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Balance")).toBeVisible();
await expect(dialog.getByText("Required")).toBeVisible();
await expect(dialog.getByText("Still needed")).toBeVisible();
return dialog;
}
+65
View File
@@ -0,0 +1,65 @@
import { expect, type Page } from "@playwright/test";
import { e2eEmailUser } from "../data/auth";
export const e2eEmailCredentials = {
email: "chanwillian0@gmail.com",
password: "12345678",
};
export const splashStartChatButtonName = "Start Chatting";
export function getSplashStartChatButton(page: Page) {
return page.getByRole("button", { name: splashStartChatButtonName });
}
export async function setEmailSessionStorage(page: Page) {
await page.evaluate((user) => {
localStorage.setItem("cozsweet:login_token", "e2e-email-token");
localStorage.setItem("cozsweet:refresh_token", "e2e-email-refresh-token");
localStorage.setItem("cozsweet:login_provider", "email");
localStorage.setItem("cozsweet:user_id", user.id);
localStorage.setItem("cozsweet:user", JSON.stringify(user));
}, e2eEmailUser);
}
export async function seedEmailSession(page: Page) {
await page.goto("/splash");
await setEmailSessionStorage(page);
}
export async function switchToEmailSignIn(page: Page) {
const otherOptionsButton = page.getByRole("button", { name: /Other Sign In Options/i });
const otherOptionsDialog = page.getByRole("dialog", { name: /Other sign-in options/i });
await expect(otherOptionsButton).toBeVisible({ timeout: 10_000 });
await expect(otherOptionsButton).toBeEnabled();
for (let attempt = 0; attempt < 3; attempt += 1) {
await otherOptionsButton.click();
try {
await expect(otherOptionsDialog).toBeVisible({ timeout: 2_000 });
break;
} catch (error) {
if (attempt === 2) throw error;
await otherOptionsButton.evaluate((button) => (button as HTMLButtonElement).click());
await page.waitForTimeout(500);
}
}
await page.getByRole("button", { name: "Email Sign In" }).click();
await expect(page.getByPlaceholder("Email")).toHaveValue(e2eEmailCredentials.email);
await expect(page.getByPlaceholder("Password")).toHaveValue(e2eEmailCredentials.password);
}
export async function submitEmailLogin(page: Page, options: { expectedUrl?: RegExp; waitForRequest?: boolean; urlTimeout?: number } = {}) {
const { expectedUrl, waitForRequest = true, urlTimeout = 10_000 } = options;
const loginButton = page.getByRole("button", { name: "Login" });
if (!waitForRequest) {
await Promise.all([expectedUrl ? page.waitForURL(expectedUrl, { timeout: urlTimeout }) : Promise.resolve(), loginButton.click()]);
return null;
}
const loginRequestPromise = page.waitForRequest("**/api/auth/login");
await loginButton.click();
const loginRequest = await loginRequestPromise;
expect(loginRequest.postDataJSON()).toMatchObject({ email: e2eEmailCredentials.email, password: e2eEmailCredentials.password, isTestAccount: true });
if (expectedUrl) await expect(page).toHaveURL(expectedUrl, { timeout: urlTimeout });
return loginRequest;
}
+41
View File
@@ -0,0 +1,41 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import { seedEmailSession } from "./auth";
export async function clearBrowserState(context: BrowserContext, page: Page, baseURL?: string) {
await context.clearCookies();
const client = await context.newCDPSession(page);
const origins = new Set([new URL(baseURL ?? "http://127.0.0.1:3000").origin, "http://127.0.0.1:3000", "http://localhost:3000"]);
for (const origin of origins) {
await client.send("Storage.clearDataForOrigin", { origin, storageTypes: "all" }).catch(() => {});
}
}
export async function enterChatFromSplash(page: Page, options: { expectedUrl?: RegExp; timeout?: number; waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle" } = {}) {
const { expectedUrl = /\/chat$/, timeout = 10_000, waitUntil } = options;
await page.goto("/splash", { waitUntil });
const startChatButton = page.getByRole("button", { name: "Start Chatting" });
await expect(startChatButton).toBeVisible({ timeout });
await expect(startChatButton).toBeEnabled();
for (let attempt = 0; attempt < 3; attempt += 1) {
await startChatButton.click();
try { await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 }); return; } catch { await page.waitForTimeout(500); }
}
await expect(page).toHaveURL(expectedUrl, { timeout });
}
export async function dismissChatInterruptions(page: Page) {
for (const buttonName of ["Later", "Cancel", "OK"]) {
const button = page.getByRole("button", { name: buttonName }).first();
if (await button.isVisible().catch(() => false)) await button.click();
}
}
export async function signInWithEmailAndOpenChat(page: Page) {
await seedEmailSession(page);
const historyResponsePromise = page.waitForResponse("**/api/chat/history**");
await page.goto("/chat");
await historyResponsePromise;
await dismissChatInterruptions(page);
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
}
+30
View File
@@ -0,0 +1,30 @@
import { expect, type Page } from "@playwright/test";
export async function completeVipPayment(page: Page) {
const createOrderRequestPromise = page.waitForRequest("**/api/payment/create-order");
const orderStatusRequestPromise = page.waitForRequest("**/api/payment/order-status**");
await page.getByRole("button", { name: /Pay and Activ/i }).click();
const createOrderRequest = await createOrderRequestPromise;
expect(createOrderRequest.postDataJSON()).toMatchObject({ planId: "vip_monthly", payChannel: "stripe" });
await orderStatusRequestPromise;
const paymentSuccessDialog = page.getByRole("alertdialog", { name: "Payment successful" });
await expect(paymentSuccessDialog).toBeVisible();
await paymentSuccessDialog.getByRole("button", { name: "OK" }).click();
}
export async function expectVipChatSubscriptionUrl(page: Page) {
await expect(page).toHaveURL(/\/subscription\?/);
const url = new URL(page.url());
expect(url.pathname).toBe("/subscription");
expect(url.searchParams.get("type")).toBe("vip");
expect(url.searchParams.get("returnTo")).toBe("chat");
}
export function expectAuthRedirectToVipChatSubscription(page: Page) {
const redirect = new URL(page.url()).searchParams.get("redirect");
expect(redirect).not.toBeNull();
const redirectUrl = new URL(redirect ?? "", "http://e2e.local");
expect(redirectUrl.pathname).toBe("/subscription");
expect(redirectUrl.searchParams.get("type")).toBe("vip");
expect(redirectUrl.searchParams.get("returnTo")).toBe("chat");
}
+6 -400
View File
@@ -1,400 +1,6 @@
export function apiEnvelope<T>(data: T) { // Backward-compatible aggregate export. Prefer importing from data/<module> in new tests.
return { export * from "./data/auth";
code: 200, export * from "./data/chat";
message: "success", export * from "./data/common";
success: true, export * from "./data/payment";
data, export * from "./data/user";
};
}
export const e2eUser = {
id: "user_e2e",
username: "E2E User",
email: "e2e@example.com",
platform: "web",
intimacy: 42,
dolBalance: 0,
relationshipStage: "close_friend",
currentMood: "happy",
preferredLanguage: "en",
createdAt: "2026-06-25T00:00:00.000Z",
lastMessageAt: "",
avatarUrl: "",
loginProvider: "guest",
isGuest: true,
isVip: false,
voiceMinutesRemaining: 0,
};
export const e2eEmailUser = {
...e2eUser,
id: "user_e2e_email",
username: "E2E Email User",
email: "chanwillian0@gmail.com",
isGuest: false,
};
export const guestLoginResponse = {
token: "e2e-guest-token",
deviceId: "e2e-device-id",
userId: e2eUser.id,
isDeviceUser: true,
expiresIn: 2_592_000,
user: e2eUser,
};
export const refreshedGuestLoginResponse = {
...guestLoginResponse,
token: "e2e-refreshed-guest-token",
};
export const emailLoginResponse = {
token: "e2e-email-token",
refreshToken: "e2e-email-refresh-token",
user: e2eEmailUser,
};
export const refreshedEmailLoginResponse = {
token: "e2e-refreshed-email-token",
refreshToken: "e2e-refreshed-email-refresh-token",
};
export const userEntitlementsResponse = {
userId: e2eEmailUser.id,
isGuest: false,
isVip: false,
vipExpiresAt: null,
creditBalance: 0,
dolBalance: 0,
};
export const emptyChatHistoryResponse = {
messages: [],
total: 0,
limit: 50,
offset: 0,
isVip: false,
privateFreeLimit: 0,
privateUsedToday: 0,
privateCanViewFree: false,
};
export const chatSendResponse = {
reply: "Hello from the E2E boyfriend.",
audioUrl: "",
messageId: "msg_e2e_reply_001",
isGuest: true,
timestamp: 1_782_356_425_363,
image: {
type: null,
url: null,
},
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
};
export function createChatSendResponse(sendCount: number) {
return {
...chatSendResponse,
reply: `Hello from the E2E boyfriend. Reply ${sendCount}.`,
messageId: `msg_e2e_reply_${String(sendCount).padStart(3, "0")}`,
timestamp: chatSendResponse.timestamp + sendCount,
};
}
export function createWeeklyLimitChatSendResponse(
usedMessageCount: number,
limit: number,
) {
return {
reply: "",
audioUrl: "",
messageId: "",
isGuest: true,
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
creditsCharged: 0,
requiredCredits: limit,
shortfallCredits: Math.max(0, limit - usedMessageCount),
timestamp: chatSendResponse.timestamp + usedMessageCount,
image: {
type: null,
url: null,
},
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "weekly_limit",
hint: "Free message limit reached.",
detail: {
type: "weekly_msg_limit",
usedThisWeek: usedMessageCount,
limit,
},
},
};
}
export const paidImageMessageId = "msg_photo_paywall_001";
export const paidImageUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
export const paidImageChatSendResponse = {
reply: "I can show you that photo after you activate VIP.",
audioUrl: "",
messageId: paidImageMessageId,
isGuest: true,
timestamp: 1_782_180_725_000,
image: {
type: "elio_schedule",
url: paidImageUrl,
},
lockDetail: {
locked: true,
showContent: true,
showUpgrade: true,
reason: "image",
hint: "Activate VIP to unlock the full photo.",
detail: null,
},
};
export function createPaidImageHistoryResponse(unlocked: boolean) {
return {
messages: [
{
role: "assistant",
type: "image",
content: unlocked
? ""
: "I can show you that photo after you activate VIP.",
id: paidImageMessageId,
created_at: "2026-06-30T00:00:00.000Z",
audioUrl: null,
image: {
type: "elio_schedule",
url: paidImageUrl,
},
lockDetail: unlocked
? {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
}
: paidImageChatSendResponse.lockDetail,
},
],
total: 1,
limit: 50,
offset: 0,
isVip: unlocked,
privateFreeLimit: 0,
privateUsedToday: 0,
privateCanViewFree: false,
};
}
export const paidImageUnlockHistoryResponse = {
unlocked: true,
reason: "ok",
totalLocked: 1,
unlockedCount: 1,
privateCount: 0,
imageCount: 1,
voiceCount: 0,
requiredCredits: 0,
currentCredits: 0,
remainingCredits: 0,
shortfallCredits: 0,
costsByMessage: {
[paidImageMessageId]: 0,
},
messageIds: [paidImageMessageId],
};
export const paidVoiceMessageId = "msg_voice_paywall_001";
export const paidVoiceAudioUrl =
"data:audio/mpeg;base64,//uQZAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAACcQCA";
export const paidVoiceHistoryResponse = {
messages: [
{
role: "assistant",
type: "voice",
content: "",
id: paidVoiceMessageId,
created_at: "2026-07-01T00:00:00.000Z",
audioUrl: paidVoiceAudioUrl,
image: {
type: null,
url: null,
},
lockDetail: {
locked: true,
showContent: true,
showUpgrade: true,
reason: "voice_message",
hint: "Unlock this voice message with credits.",
detail: null,
},
},
],
total: 1,
limit: 50,
offset: 0,
isVip: false,
privateFreeLimit: 0,
privateUsedToday: 0,
privateCanViewFree: false,
};
export const insufficientCreditsUnlockVoiceResponse = {
unlocked: false,
content: "",
reason: "insufficient_balance",
creditBalance: 3,
creditsCharged: 0,
requiredCredits: 20,
shortfallCredits: 17,
lockDetail: {
locked: true,
showContent: true,
showUpgrade: true,
reason: "voice_message",
hint: "Unlock this voice message with credits.",
detail: null,
},
};
export const insufficientCreditsUnlockImageResponse = {
unlocked: false,
content: "",
reason: "insufficient_balance",
creditBalance: 3,
creditsCharged: 0,
requiredCredits: 30,
shortfallCredits: 27,
lockDetail: {
locked: true,
showContent: true,
showUpgrade: true,
reason: "image",
hint: "Unlock this high-definition image with credits.",
detail: null,
},
};
export const paymentPlansResponse = {
plans: [
{
planId: "vip_monthly",
planName: "Monthly",
orderType: "vip_monthly",
amountCents: 999,
originalAmountCents: 1999,
dailyPriceCents: 33,
currency: "USD",
vipDays: 30,
dolAmount: null,
creditBalance: 300,
},
{
planId: "vip_quarterly",
planName: "Quarterly",
orderType: "vip_quarterly",
amountCents: 2499,
originalAmountCents: 5999,
dailyPriceCents: 28,
currency: "USD",
vipDays: 90,
dolAmount: null,
creditBalance: 1000,
},
{
planId: "dol_voice_30",
planName: "Voice Pack",
orderType: "dol_voice",
amountCents: 499,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
vipDays: null,
dolAmount: 30,
creditBalance: 30,
},
],
};
export const tipPaymentPlansResponse = {
plans: [
{
planId: "tip_coffee_usd_4_99",
planName: "Small Coffee",
orderType: "tip",
tipType: "coffee_small",
description: "Buy Elio a small coffee",
amountCents: 499,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
},
{
planId: "tip_coffee_usd_9_99",
planName: "Medium Coffee",
orderType: "tip",
tipType: "coffee_medium",
description: "Buy Elio a medium coffee",
amountCents: 999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
},
{
planId: "tip_coffee_usd_19_99",
planName: "Large Coffee",
orderType: "tip",
tipType: "coffee_large",
description: "Buy Elio a large coffee",
amountCents: 1999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
},
],
};
export const paymentOrderId = "order_e2e_vip_monthly";
export const createPaymentOrderResponse = {
orderId: paymentOrderId,
payParams: {
provider: "stripe",
clientSecret: "pi_e2e_secret_mock",
},
};
export const paidPaymentOrderStatusResponse = {
orderId: paymentOrderId,
status: "paid",
orderType: "vip_monthly",
planId: "vip_monthly",
};
export const vipStatusResponse = {
isVip: false,
expiresAt: null,
};
+5 -224
View File
@@ -1,224 +1,5 @@
import { expect, type BrowserContext, type Page } from "@playwright/test"; // Backward-compatible aggregate export. Prefer importing from helpers/<module> in new tests.
export * from "./helpers/assertions";
export const e2eEmailCredentials = { export * from "./helpers/auth";
email: "chanwillian0@gmail.com", export * from "./helpers/chat";
password: "12345678", export * from "./helpers/payment";
};
export const splashStartChatButtonName = "Start Chatting";
export function getSplashStartChatButton(page: Page) {
return page.getByRole("button", { name: splashStartChatButtonName });
}
export async function clearBrowserState(
context: BrowserContext,
page: Page,
baseURL?: string,
) {
await context.clearCookies();
const client = await context.newCDPSession(page);
const origins = new Set([
new URL(baseURL ?? "http://127.0.0.1:3000").origin,
"http://127.0.0.1:3000",
"http://localhost:3000",
]);
for (const origin of origins) {
await client
.send("Storage.clearDataForOrigin", {
origin,
storageTypes: "all",
})
.catch(() => {});
}
}
export async function enterChatFromSplash(
page: Page,
options: {
expectedUrl?: RegExp;
timeout?: number;
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
} = {},
) {
const {
expectedUrl = /\/chat$/,
timeout = 10_000,
waitUntil,
} = options;
await page.goto("/splash", { waitUntil });
const startChatButton = getSplashStartChatButton(page);
await expect(startChatButton).toBeVisible({ timeout });
await expect(startChatButton).toBeEnabled();
for (let attempt = 0; attempt < 3; attempt += 1) {
await startChatButton.click();
try {
await expect(page).toHaveURL(expectedUrl, { timeout: 3_000 });
return;
} catch {
await page.waitForTimeout(500);
}
}
await expect(page).toHaveURL(expectedUrl, { timeout });
}
export async function dismissChatInterruptions(page: Page) {
for (const buttonName of ["Later", "Cancel", "OK"]) {
const button = page.getByRole("button", { name: buttonName }).first();
if (await button.isVisible().catch(() => false)) {
await button.click();
}
}
}
export async function switchToEmailSignIn(page: Page) {
const otherOptionsButton = page.getByRole("button", {
name: /Other Sign In Options/i,
});
const otherOptionsDialog = page.getByRole("dialog", {
name: /Other sign-in options/i,
});
await expect(otherOptionsButton).toBeVisible({ timeout: 10_000 });
await expect(otherOptionsButton).toBeEnabled();
for (let attempt = 0; attempt < 3; attempt += 1) {
await otherOptionsButton.click();
try {
await expect(otherOptionsDialog).toBeVisible({ timeout: 2_000 });
break;
} catch (error) {
if (attempt === 2) throw error;
await otherOptionsButton.evaluate((button) =>
(button as HTMLButtonElement).click(),
);
if (await otherOptionsDialog.isVisible().catch(() => false)) {
break;
}
await page.waitForTimeout(500);
}
}
await page.getByRole("button", { name: "Email Sign In" }).click();
await expect(page.getByPlaceholder("Email")).toHaveValue(
e2eEmailCredentials.email,
);
await expect(page.getByPlaceholder("Password")).toHaveValue(
e2eEmailCredentials.password,
);
}
export async function submitEmailLogin(
page: Page,
options: {
expectedUrl?: RegExp;
waitForRequest?: boolean;
urlTimeout?: number;
} = {},
) {
const {
expectedUrl,
waitForRequest = true,
urlTimeout = 10_000,
} = options;
const loginButton = page.getByRole("button", { name: "Login" });
if (!waitForRequest) {
await Promise.all([
expectedUrl
? page.waitForURL(expectedUrl, { timeout: urlTimeout })
: Promise.resolve(),
loginButton.click(),
]);
return null;
}
const loginRequestPromise = page.waitForRequest("**/api/auth/login");
await loginButton.click();
const loginRequest = await loginRequestPromise;
expect(loginRequest.postDataJSON()).toMatchObject({
email: e2eEmailCredentials.email,
password: e2eEmailCredentials.password,
isTestAccount: true,
});
if (expectedUrl) {
await expect(page).toHaveURL(expectedUrl, { timeout: urlTimeout });
}
return loginRequest;
}
export async function signInWithEmailAndOpenChat(page: Page) {
await page.goto("/auth");
await switchToEmailSignIn(page);
const historyResponsePromise = page
.waitForResponse("**/api/chat/history**", { timeout: 10_000 })
.catch(() => null);
await submitEmailLogin(page, { expectedUrl: /\/chat$/ });
await historyResponsePromise;
await dismissChatInterruptions(page);
}
export async function completeVipPayment(page: Page) {
const createOrderRequestPromise = page.waitForRequest(
"**/api/payment/create-order",
);
const orderStatusRequestPromise = page.waitForRequest(
"**/api/payment/order-status**",
);
await page.getByRole("button", { name: /Pay and Activ/i }).click();
const createOrderRequest = await createOrderRequestPromise;
expect(createOrderRequest.postDataJSON()).toMatchObject({
planId: "vip_monthly",
payChannel: "stripe",
});
await orderStatusRequestPromise;
const paymentSuccessDialog = page.getByRole("alertdialog", {
name: "Payment successful",
});
await expect(paymentSuccessDialog).toBeVisible();
await paymentSuccessDialog.getByRole("button", { name: "OK" }).click();
}
export async function expectInsufficientCreditsDialog(page: Page) {
const insufficientCreditsDialog = page.getByRole("dialog", {
name: "Not enough credits",
});
await expect(insufficientCreditsDialog).toBeVisible();
await expect(insufficientCreditsDialog.getByText("Balance")).toBeVisible();
await expect(insufficientCreditsDialog.getByText("Required")).toBeVisible();
await expect(
insufficientCreditsDialog.getByText("Still needed"),
).toBeVisible();
return insufficientCreditsDialog;
}
export async function expectVipChatSubscriptionUrl(page: Page) {
await expect(page).toHaveURL(/\/subscription\?/);
const url = new URL(page.url());
expect(url.pathname).toBe("/subscription");
expect(url.searchParams.get("type")).toBe("vip");
expect(url.searchParams.get("returnTo")).toBe("chat");
}
export function expectAuthRedirectToVipChatSubscription(page: Page) {
const redirect = new URL(page.url()).searchParams.get("redirect");
expect(redirect).not.toBeNull();
const redirectUrl = new URL(redirect ?? "", "http://e2e.local");
expect(redirectUrl.pathname).toBe("/subscription");
expect(redirectUrl.searchParams.get("type")).toBe("vip");
expect(redirectUrl.searchParams.get("returnTo")).toBe("chat");
}
@@ -25,9 +25,7 @@ test("user can email login from other sign-in options and return to chat", async
await switchToEmailSignIn(page); await switchToEmailSignIn(page);
const loginRequest = await submitEmailLogin(page, { expectedUrl: /\/chat$/ }); const loginRequest = await submitEmailLogin(page, { expectedUrl: /\/chat$/ });
expect(loginRequest?.postDataJSON()).toMatchObject({ expect(["desktop", "android"]).toContain(loginRequest?.postDataJSON().platform);
platform: "desktop",
});
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
@@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { import {
clearBrowserState, clearBrowserState,
getSplashStartChatButton,
signInWithEmailAndOpenChat, signInWithEmailAndOpenChat,
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
@@ -26,8 +27,6 @@ test("user can log out from the sidebar after email login", async ({
const logoutRequest = await logoutRequestPromise; const logoutRequest = await logoutRequestPromise;
expect(logoutRequest.method()).toBe("POST"); expect(logoutRequest.method()).toBe("POST");
await expect(page).toHaveURL(/\/chat$/); await expect(page).toHaveURL(/\/splash$/);
await expect( await expect(getSplashStartChatButton(page)).toBeVisible();
page.getByRole("button", { name: "Sign up to unlock more features" }),
).toBeVisible();
}); });
@@ -24,9 +24,7 @@ test.beforeEach(async ({ baseURL, context, page }) => {
}); });
}); });
test("guest hits the chat quantity limit and can continue to login and subscription", async ({ test("guest uses the shared chat limit top-up flow", async ({ page }) => {
page,
}) => {
await enterChatFromSplash(page); await enterChatFromSplash(page);
await dismissChatInterruptions(page); await dismissChatInterruptions(page);
@@ -81,9 +79,10 @@ test("guest hits the chat quantity limit and can continue to login and subscript
expect(limitTriggeredAt).toBe(mockedLimitTriggerAt); expect(limitTriggeredAt).toBe(mockedLimitTriggerAt);
await expect(quantityLimitBanner).toBeVisible(); await expect(quantityLimitBanner).toBeVisible();
await expect(messageInput).toHaveCount(0); await expect(
quantityLimitBanner.getByText("Free chats refresh every day."),
const topUpButton = page.getByRole("button", { ).toBeVisible();
const topUpButton = quantityLimitBanner.getByRole("button", {
name: "Top up credits to continue", name: "Top up credits to continue",
}); });
await expect(topUpButton).toBeVisible(); await expect(topUpButton).toBeVisible();
+16 -6
View File
@@ -9,10 +9,15 @@ import {
enterChatFromSplash, enterChatFromSplash,
expectInsufficientCreditsDialog, expectInsufficientCreditsDialog,
expectVipChatSubscriptionUrl, expectVipChatSubscriptionUrl,
setEmailSessionStorage,
submitEmailLogin, submitEmailLogin,
switchToEmailSignIn, switchToEmailSignIn,
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
const paidImageOverlayUrl = new RegExp(
`/chat\\?image=${paidImageMessageId}$`,
);
test.beforeEach(async ({ baseURL, context, page }) => { test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL); await clearBrowserState(context, page, baseURL);
await mockCoreApis(page, { await mockCoreApis(page, {
@@ -44,7 +49,7 @@ test("guest can unlock a paid image after email login and subscription payment",
await expect(paidImageButton).toBeVisible(); await expect(paidImageButton).toBeVisible();
await paidImageButton.click(); await paidImageButton.click();
await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`)); await expect(page).toHaveURL(paidImageOverlayUrl);
const lockedImageDialog = page.getByRole("dialog", { const lockedImageDialog = page.getByRole("dialog", {
name: "Locked fullscreen image", name: "Locked fullscreen image",
@@ -57,16 +62,19 @@ test("guest can unlock a paid image after email login and subscription payment",
await expect(page).toHaveURL(/\/auth\?redirect=/); await expect(page).toHaveURL(/\/auth\?redirect=/);
expect(new URL(page.url()).searchParams.get("redirect")).toBe( expect(new URL(page.url()).searchParams.get("redirect")).toBe(
`/chat/image/${paidImageMessageId}`, `/chat?image=${paidImageMessageId}`,
); );
await switchToEmailSignIn(page); await switchToEmailSignIn(page);
await submitEmailLogin(page, {
expectedUrl: paidImageOverlayUrl,
});
await setEmailSessionStorage(page);
const unlockRequestPromise = page.waitForRequest( const unlockRequestPromise = page.waitForRequest(
"**/api/chat/unlock-private", "**/api/chat/unlock-private",
); );
await submitEmailLogin(page, { await page.goto(`/chat?image=${paidImageMessageId}`);
expectedUrl: new RegExp(`/chat/image/${paidImageMessageId}$`),
});
const unlockRequest = await unlockRequestPromise; const unlockRequest = await unlockRequestPromise;
expect(unlockRequest.postDataJSON()).toMatchObject({ expect(unlockRequest.postDataJSON()).toMatchObject({
@@ -81,5 +89,7 @@ test("guest can unlock a paid image after email login and subscription payment",
await expectVipChatSubscriptionUrl(page); await expectVipChatSubscriptionUrl(page);
await completeVipPayment(page); await completeVipPayment(page);
await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`)); await expect(page).toHaveURL(/\/chat(?:\?image=msg_photo_paywall_001)?$/);
expect(new URL(page.url()).pathname).toBe("/chat");
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
}); });
@@ -10,6 +10,10 @@ import {
signInWithEmailAndOpenChat, signInWithEmailAndOpenChat,
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
const paidImageOverlayUrl = new RegExp(
`/chat\\?image=${paidImageMessageId}$`,
);
test.beforeEach(async ({ baseURL, context, page }) => { test.beforeEach(async ({ baseURL, context, page }) => {
await clearBrowserState(context, page, baseURL); await clearBrowserState(context, page, baseURL);
await mockCoreApis(page, { await mockCoreApis(page, {
@@ -29,7 +33,7 @@ test("user sees insufficient credits when unlocking a paid image from fullscreen
await expect(paidImageButton).toBeVisible({ timeout: 10_000 }); await expect(paidImageButton).toBeVisible({ timeout: 10_000 });
await paidImageButton.click(); await paidImageButton.click();
await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`)); await expect(page).toHaveURL(paidImageOverlayUrl);
const lockedImageDialog = page.getByRole("dialog", { const lockedImageDialog = page.getByRole("dialog", {
name: "Locked fullscreen image", name: "Locked fullscreen image",
@@ -21,7 +21,7 @@ test("user sees insufficient credits when unlocking a paid voice message and can
}) => { }) => {
await signInWithEmailAndOpenChat(page); await signInWithEmailAndOpenChat(page);
await expect( await expect(
page.getByText("Unlock this voice message with credits."), page.getByText("Elio has a locked voice message for you."),
).toBeVisible({ timeout: 10_000 }); ).toBeVisible({ timeout: 10_000 });
const unlockButton = page.getByRole("button", { const unlockButton = page.getByRole("button", {