修复:将页面异步副作用绑定到当前归属
This commit is contained in:
1 parent
b3a66d97c8
commit
eca3aa8fe5
6 files changed
+623
-90
No files matched your search
@@ -0,0 +1,453 @@
|
||||
/** 页面/会话/草稿归属:真实页面与真实登录,只有外部 API 响应受控。 */
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
login,
|
||||
loginAccount,
|
||||
loginPassword,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => (resolve = done));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function respond(route, data, status = 200) {
|
||||
return route.fulfill({
|
||||
json: { status, msg: status === 200 ? "" : "本地延迟失败", data },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
|
||||
async function boundary(page, handle) {
|
||||
const requests = [];
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname.replace(/^\/console\/v1/, "");
|
||||
const entry = {
|
||||
method: request.method(),
|
||||
path,
|
||||
authenticated: !!request.headers().authorization,
|
||||
};
|
||||
requests.push(entry);
|
||||
if (await handle(route, path, entry, url)) return;
|
||||
return route.fallback();
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
async function releaseResponse(page, release, path) {
|
||||
const response = page.waitForResponse(
|
||||
(reply) => new URL(reply.url()).pathname === `/console/v1${path}`,
|
||||
);
|
||||
release.resolve();
|
||||
await (await response).finished();
|
||||
// Absence assertions observe a bounded idle window after the real fetch has completed.
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
async function signInOnCurrentDocument(page) {
|
||||
await page.getByLabel("账号", { exact: true }).fill(loginAccount);
|
||||
await page.getByLabel("密码", { exact: true }).fill(loginPassword);
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname !== "/login");
|
||||
await page.getByRole("button", { name: "用户菜单", exact: true }).waitFor();
|
||||
}
|
||||
|
||||
async function logoutAndSignIn(page) {
|
||||
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname === "/login");
|
||||
await signInOnCurrentDocument(page);
|
||||
}
|
||||
|
||||
for (const transition of ["current", "leave", "reauthenticate"]) {
|
||||
test(
|
||||
`message detail ownership after ${transition}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const requests = await boundary(page, async (route, path) => {
|
||||
if (path === "/account/notification-preferences") {
|
||||
await respond(route, {
|
||||
email_enabled: false,
|
||||
sms_enabled: false,
|
||||
email_available: true,
|
||||
sms_available: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/client_care/mail/list") {
|
||||
await respond(route, { list: [], count: 0 });
|
||||
return true;
|
||||
}
|
||||
if (path === "/client_care/mail/701") {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, {
|
||||
mail: {
|
||||
id: 701,
|
||||
title: "归属测试消息",
|
||||
content: "<p>本地正文</p>",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/client_care/mail/read") {
|
||||
await respond(route, {});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await login(page, "/messages?id=701");
|
||||
await started.promise;
|
||||
if (transition !== "current") await page.keyboard.press("Escape");
|
||||
if (transition === "leave") {
|
||||
await page
|
||||
.getByRole("navigation", { name: "会员中心导航" })
|
||||
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
}
|
||||
if (transition === "reauthenticate") await logoutAndSignIn(page);
|
||||
await releaseResponse(page, release, "/client_care/mail/701");
|
||||
const reads = requests.filter(
|
||||
(entry) => entry.path === "/client_care/mail/read",
|
||||
);
|
||||
assert.equal(
|
||||
reads.length,
|
||||
transition === "current" ? 1 : 0,
|
||||
"Only the current detail owner may derive mark-read HTTP",
|
||||
);
|
||||
return {
|
||||
transition,
|
||||
markReadRequests: reads.length,
|
||||
authenticated: reads.every((entry) => entry.authenticated),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const routePath of ["/tickets/create", "/tickets/71"]) {
|
||||
for (const transition of ["current", "leave"]) {
|
||||
test(
|
||||
`ticket attachment queue ${routePath} after ${transition}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
let uploads = 0;
|
||||
const requests = await boundary(page, async (route, path) => {
|
||||
if (path === "/ticket/config") {
|
||||
await respond(route, { ticket_notice_open: 0 });
|
||||
return true;
|
||||
}
|
||||
if (["/ticket/type", "/ticket/department"].includes(path)) {
|
||||
await respond(route, { list: [{ id: 1, name: "本地支持" }] });
|
||||
return true;
|
||||
}
|
||||
if (path === "/ticket/71") {
|
||||
await respond(route, {
|
||||
ticket: {
|
||||
id: 71,
|
||||
title: "本地工单",
|
||||
content: "<p>本地内容</p>",
|
||||
can_operate: 1,
|
||||
attachment: [],
|
||||
replies: [],
|
||||
create_time: 1700000000,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path === "/upload") {
|
||||
uploads += 1;
|
||||
if (uploads === 1) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
await respond(route, {
|
||||
save_name: `local-attachment-${uploads}.txt`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
await login(page, routePath);
|
||||
const input = page.locator('input[type="file"]');
|
||||
await input.setInputFiles(
|
||||
[1, 2].map((id) => ({
|
||||
name: `local-${id}.txt`,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("non-sensitive local fixture"),
|
||||
})),
|
||||
);
|
||||
await started.promise;
|
||||
if (transition === "leave") {
|
||||
await page
|
||||
.getByRole("navigation", { name: "会员中心导航" })
|
||||
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||
.click();
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === "/security/ssh-keys",
|
||||
);
|
||||
}
|
||||
await releaseResponse(page, release, "/upload");
|
||||
assert.equal(
|
||||
uploads,
|
||||
transition === "current" ? 2 : 1,
|
||||
"A departed upload owner must not send the next attachment",
|
||||
);
|
||||
if (transition === "current")
|
||||
await page.getByText("local-2.txt", { exact: true }).waitFor();
|
||||
return {
|
||||
routePath,
|
||||
transition,
|
||||
uploads,
|
||||
authenticated: requests
|
||||
.filter((entry) => entry.path === "/upload")
|
||||
.every((entry) => entry.authenticated),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const transition of ["current", "leave"]) {
|
||||
test(
|
||||
`password reset navigation after ${transition}`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
await boundary(page, async (route, path) => {
|
||||
if (path !== "/account/password_reset") return false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(route, {});
|
||||
return true;
|
||||
});
|
||||
await page.goto(`${appOrigin}/forgot-password`);
|
||||
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
|
||||
await page.getByLabel("验证码 *", { exact: true }).fill("123456");
|
||||
await page
|
||||
.getByLabel("新密码 *", { exact: true })
|
||||
.fill("local-test-password");
|
||||
await page
|
||||
.getByLabel("确认新密码 *", { exact: true })
|
||||
.fill("local-test-password");
|
||||
await page
|
||||
.getByRole("button", { name: "重置密码", exact: true })
|
||||
.click();
|
||||
await started.promise;
|
||||
if (transition === "leave") {
|
||||
await page
|
||||
.getByRole("link", { name: "想起密码了?返回登录", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("link", { name: "注册账号", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/register");
|
||||
}
|
||||
await releaseResponse(page, release, "/account/password_reset");
|
||||
assert.equal(
|
||||
new URL(page.url()).pathname,
|
||||
transition === "current" ? "/login" : "/register",
|
||||
"Only the current reset operation may navigate",
|
||||
);
|
||||
return { transition, finalRoute: new URL(page.url()).pathname };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const action of ["options", "submit", "reply", "urge", "close"]) {
|
||||
test(
|
||||
`ticket ${action} cannot derive work after leaving`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const detail = ["reply", "urge", "close"].includes(action);
|
||||
const heldPath =
|
||||
action === "options"
|
||||
? "/host"
|
||||
: action === "submit"
|
||||
? "/ticket"
|
||||
: `/ticket/71/${action}`;
|
||||
let hostPages = 0;
|
||||
let detailReads = 0;
|
||||
await boundary(page, async (route, path, entry) => {
|
||||
if (path === "/ticket/config") {
|
||||
await respond(route, { ticket_notice_open: 0 });
|
||||
return true;
|
||||
}
|
||||
if (["/ticket/type", "/ticket/department"].includes(path)) {
|
||||
await respond(route, { list: [{ id: 1, name: "本地支持" }] });
|
||||
return true;
|
||||
}
|
||||
if (path === "/ticket/71" && entry.method === "GET") {
|
||||
detailReads += 1;
|
||||
await respond(route, {
|
||||
ticket: {
|
||||
id: 71,
|
||||
title: "本地工单",
|
||||
content: "<p>本地内容</p>",
|
||||
can_operate: 1,
|
||||
attachment: [],
|
||||
replies: [],
|
||||
create_time: 1700000000,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (path !== heldPath) return false;
|
||||
if (action === "options") hostPages += 1;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
await respond(
|
||||
route,
|
||||
action === "options"
|
||||
? { list: [{ id: 1, name: "本地实例" }], count: 2 }
|
||||
: { id: 71 },
|
||||
);
|
||||
return true;
|
||||
});
|
||||
await login(page, detail ? "/tickets/71" : "/tickets/create");
|
||||
if (action === "submit") {
|
||||
await page
|
||||
.getByPlaceholder("输入工单标题", { exact: true })
|
||||
.fill("本地测试标题");
|
||||
await page
|
||||
.getByRole("textbox", { name: "工单内容", exact: true })
|
||||
.fill("本地测试内容");
|
||||
await page
|
||||
.getByRole("button", { name: "创建工单", exact: true })
|
||||
.click();
|
||||
}
|
||||
if (detail) {
|
||||
await page
|
||||
.getByRole("heading", { name: "本地工单", exact: true })
|
||||
.waitFor();
|
||||
if (action === "reply")
|
||||
await page
|
||||
.getByRole("textbox", { name: "回复内容", exact: true })
|
||||
.fill("本地测试回复");
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: { reply: "提交回复", urge: "催单", close: "关闭工单" }[
|
||||
action
|
||||
],
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
await started.promise;
|
||||
await page
|
||||
.getByRole("navigation", { name: "会员中心导航" })
|
||||
.getByRole("link", { name: "SSH 密钥", exact: true })
|
||||
.click();
|
||||
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
||||
await releaseResponse(page, release, heldPath);
|
||||
assert.equal(
|
||||
new URL(page.url()).pathname,
|
||||
"/security/ssh-keys",
|
||||
"Old ticket completion must not navigate the current page",
|
||||
);
|
||||
if (action === "options")
|
||||
assert.equal(
|
||||
hostPages,
|
||||
1,
|
||||
"Departed options loader cannot fetch the next host page",
|
||||
);
|
||||
if (detail)
|
||||
assert.equal(
|
||||
detailReads,
|
||||
1,
|
||||
"Departed ticket write cannot derive a detail reload",
|
||||
);
|
||||
return { action, currentRoutePreserved: true, hostPages, detailReads };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const outcome of ["success", "error"]) {
|
||||
test(
|
||||
`SSH save ${outcome} cannot own a reopened editor`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
let saves = 0;
|
||||
await boundary(page, async (route, path, entry) => {
|
||||
if (path !== "/ssh_key" || entry.method !== "POST") return false;
|
||||
saves += 1;
|
||||
if (saves === 1) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
await respond(
|
||||
route,
|
||||
{},
|
||||
saves === 1 && outcome === "error" ? 422 : 200,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
await login(page);
|
||||
await page.getByText("audit-01", { exact: true }).waitFor();
|
||||
await page
|
||||
.getByRole("button", { name: "添加密钥", exact: true })
|
||||
.click();
|
||||
let editor = page.getByRole("dialog", {
|
||||
name: "添加密钥",
|
||||
exact: true,
|
||||
});
|
||||
await editor.getByLabel("名称 *", { exact: true }).fill("old-draft");
|
||||
await editor
|
||||
.getByLabel("公钥内容 *", { exact: true })
|
||||
.fill("ssh-ed25519 local-old-public-key");
|
||||
await editor.getByRole("button", { name: "保存", exact: true }).click();
|
||||
await started.promise;
|
||||
await editor.getByRole("button", { name: "取消", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "添加密钥", exact: true })
|
||||
.click();
|
||||
editor = page.getByRole("dialog", { name: "添加密钥", exact: true });
|
||||
await editor.getByLabel("名称 *", { exact: true }).fill("new-draft");
|
||||
await editor
|
||||
.getByLabel("公钥内容 *", { exact: true })
|
||||
.fill("ssh-ed25519 local-new-public-key");
|
||||
await releaseResponse(page, release, "/ssh_key");
|
||||
assert.equal(
|
||||
await editor.isVisible(),
|
||||
true,
|
||||
"Old completion cannot close the new editor",
|
||||
);
|
||||
assert.equal(
|
||||
await editor.getByLabel("名称 *", { exact: true }).inputValue(),
|
||||
"new-draft",
|
||||
);
|
||||
assert.equal(
|
||||
await editor.getByRole("alert").count(),
|
||||
0,
|
||||
"Old failure cannot become the new draft's error",
|
||||
);
|
||||
await editor.getByRole("button", { name: "保存", exact: true }).click();
|
||||
await editor.waitFor({ state: "hidden" });
|
||||
assert.equal(saves, 2, "The new editor remains saveable");
|
||||
return { outcome, newerDraftPreserved: true, saves };
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user