test("Sends still in flight when the next one is fired all land", async ({
page,
factories,
}) => {
const { match, alpha } = awaitcreateMatch(factories);
awaitimpersonate(page, alpha[0].id);
awaitnewSendouQMatchPage(page).goto(match.id);
// long enough that every send below is still unacknowledged when the next firesawaitslowMessageSends(page, 1000);
const chat = newChatSidebar(page).chat();
const sent = ["msg-one", "msg-two", "msg-three"];
for (const contents of sent) {
await chat.send(contents);
}
awaitexpect(chat.locators.pendingMessages).toHaveCount(0);
awaitexpect(chat.locators.messages).toHaveCount(sent.length);
for (const contents of sent) {
awaitexpect(chat.message(contents)).toBeVisible();
}
awaitexpect(page.getByText("Error happened")).toHaveCount(0);
// the list still takes a send, rather than being wedged by the earlier onesawait chat.send("msg-four");
awaitexpect(chat.message("msg-four")).toBeVisible();
})
A message sent before the event stream connected still arrives
Open second userbrowser, workerBaseURL, bravo[0].id→ other
const other = awaitopenSecondUser(browser, workerBaseURL, bravo[0].id);
Try
try {
awaitnewSendouQMatchPage(other.page).goto(match.id);
const theirChat = newChatSidebar(other.page).chat();
await theirChat.send("sent before you connected");
awaitexpect(theirChat.locators.pendingMessages).toHaveCount(0);
awaitexpect(chat.message("sent before you connected")).toHaveCount(0);
blocked = false;
// the stream comes up and the gap it left behind is caught up onawaitexpect(chat.locators.composer).toBeEnabled({ timeout: 15_000 });
awaitexpect(chat.message("sent before you connected")).toBeVisible({
timeout: 10_000,
});
// and it keeps delivering live from there onawait theirChat.send("sent after you connected");
awaitexpect(chat.message("sent after you connected")).toBeVisible();
} finally {
await other.close();
}
awaitexpect(chat.message("sent before you connected")).toBeVisible({
timeout: 10_000,
});
and it keeps delivering live from there on
Send“sent after you connected”
await theirChat.send("sent after you connected");
message“sent after you connected” is visible
awaitexpect(chat.message("sent after you connected")).toBeVisible();
Source
test("A message sent before the event stream connected still arrives", async ({
page,
browser,
workerBaseURL,
factories,
}) => {
const { match, alpha, bravo } = awaitcreateMatch(factories);
awaitimpersonate(page, alpha[0].id);
// the stream cannot be established while `blocked` holds, so the page loads// with its chat open but nothing listening behind itlet blocked = true;
await page.route(/\/sse$/, (route) =>
blocked ? route.abort() : route.fallback(),
);
awaitnewSendouQMatchPage(page).goto(match.id);
const chat = newChatSidebar(page).chat();
// the composer being disabled is the page telling us it has no streamawaitexpect(chat.locators.composer).toBeDisabled();
const other = awaitopenSecondUser(browser, workerBaseURL, bravo[0].id);
try {
awaitnewSendouQMatchPage(other.page).goto(match.id);
const theirChat = newChatSidebar(other.page).chat();
await theirChat.send("sent before you connected");
awaitexpect(theirChat.locators.pendingMessages).toHaveCount(0);
awaitexpect(chat.message("sent before you connected")).toHaveCount(0);
blocked = false;
// the stream comes up and the gap it left behind is caught up onawaitexpect(chat.locators.composer).toBeEnabled({ timeout: 15_000 });
awaitexpect(chat.message("sent before you connected")).toBeVisible({
timeout: 10_000,
});
// and it keeps delivering live from there onawait theirChat.send("sent after you connected");
awaitexpect(chat.message("sent after you connected")).toBeVisible();
} finally {
await other.close();
}
})
Split view: a participant gets the match and their own group chat, never the opponent's
test("Split view: a participant gets the match and their own group chat, never the opponent's", async ({
page,
factories,
}) => {
const { match, alpha } = awaitcreateMatch(factories);
awaitimpersonate(page, alpha[0].id);
awaitnewSendouQMatchPage(page).goto(match.id);
const chat = newChatSidebar(page);
// the route opens its rooms itself, as tabs while the view is too short to stack themawaitexpect(chat.locators.splitPanels).toHaveCount(2);
awaitexpect(chat.locators.roomTabs).toHaveText(["Match", "Group"]);
await chat.selectRoomTab("Group");
await chat.chat().send("only for my group");
awaitexpect(chat.chat().message("only for my group")).toBeVisible();
await chat.selectRoomTab("Match");
awaitexpect(chat.chat().message("only for my group")).toHaveCount(0);
// given the height, they stack: match on top, group belowawait page.setViewportSize({ width: 1280, height: 1200 });
awaitexpect(chat.locators.roomTabs).toHaveCount(0);
awaitexpect(chat.locators.splitPanelHeaders).toHaveText([
"Match",
"Group",
]);
awaitexpect(chat.chat(1).message("only for my group")).toBeVisible();
awaitexpect(chat.chat(0).message("only for my group")).toHaveCount(0);
await chat.backToRoomList();
// the two rooms the route opened are one combined row, and there is no thirdawaitexpect(chat.locators.roomRows).toHaveCount(1);
awaitexpect(chat.locators.roomRows).toContainText("Match · Group");
})
Unread badge: counts messages arriving while the sidebar is closed and clears on open
try {
awaitnewFrontPage(phone.page).goto();
awaitnewFrontPage(laptop.page).goto();
const phoneChat = newChatSidebar(phone.page);
const laptopChat = newChatSidebar(laptop.page);
awaitnewChatSidebar(page).chat().send("read me");
awaitexpect(phoneChat.locators.toggleUnreadBadge).toHaveText("1");
awaitexpect(laptopChat.locators.toggleUnreadBadge).toHaveText("1");
const readPosts: string[] = [];
phone.page.on("request", (request) => {
if (
request.method() === "POST" &&
/\/chat\/rooms\/\d+\/read$/.test(newURL(request.url()).pathname)
) {
readPosts.push(request.url());
}
});
await phoneChat.open();
await phoneChat.openRoom(matchRoom);
// the read indicator is posted debounced, so leaving the page this// soon after reading only tells the server if the unload flushes itexpect(readPosts).toHaveLength(0);
awaitnewFrontPage(phone.page).goto();
awaitnewFrontPage(laptop.page).goto();
await laptopChat.open();
// the row being there proves the list was fetched, so the missing badge// is the server's count rather than a list that has not arrived yetawaitexpect(laptopChat.roomRow(matchRoom)).toBeVisible();
awaitexpect(laptopChat.roomRowUnreadBadge(matchRoom)).toHaveCount(0);
} finally {
awaitPromise.all([phone.close(), laptop.close()]);
}
test("Read state follows the user, not the device they read on", async ({
page,
browser,
workerBaseURL,
factories,
}) => {
const { match, alpha, bravo } = awaitcreateMatch(factories);
const matchRoom = `Match #${match.id}`;
awaitimpersonate(page, alpha[0].id);
awaitnewSendouQMatchPage(page).goto(match.id);
const phone = awaitopenSecondUser(browser, workerBaseURL, bravo[0].id);
const laptop = awaitopenSecondUser(browser, workerBaseURL, bravo[0].id);
try {
awaitnewFrontPage(phone.page).goto();
awaitnewFrontPage(laptop.page).goto();
const phoneChat = newChatSidebar(phone.page);
const laptopChat = newChatSidebar(laptop.page);
awaitnewChatSidebar(page).chat().send("read me");
awaitexpect(phoneChat.locators.toggleUnreadBadge).toHaveText("1");
awaitexpect(laptopChat.locators.toggleUnreadBadge).toHaveText("1");
const readPosts: string[] = [];
phone.page.on("request", (request) => {
if (
request.method() === "POST" &&
/\/chat\/rooms\/\d+\/read$/.test(newURL(request.url()).pathname)
) {
readPosts.push(request.url());
}
});
await phoneChat.open();
await phoneChat.openRoom(matchRoom);
// the read indicator is posted debounced, so leaving the page this// soon after reading only tells the server if the unload flushes itexpect(readPosts).toHaveLength(0);
awaitnewFrontPage(phone.page).goto();
awaitnewFrontPage(laptop.page).goto();
await laptopChat.open();
// the row being there proves the list was fetched, so the missing badge// is the server's count rather than a list that has not arrived yetawaitexpect(laptopChat.roomRow(matchRoom)).toBeVisible();
awaitexpect(laptopChat.roomRowUnreadBadge(matchRoom)).toHaveCount(0);
} finally {
awaitPromise.all([phone.close(), laptop.close()]);
}
})
Leaving a group takes its chat out of the sidebar without a reload
try {
awaitnewSendouQMatchPage(observer.page).goto(match.id);
const staffChat = newChatSidebar(observer.page);
// an observer chats alongside the participants in the match roomawaitexpect(staffChat.locators.openChats).toHaveCount(1);
await staffChat.chat().send("staff here");
awaitexpect(participantChat.chat().message("staff here")).toBeVisible();
await staffChat.backToRoomList();
// the match room they are observing, plus both group chats to readawaitexpect(staffChat.locators.roomRows).toHaveCount(3);
await staffChat.openRoom("Group Alpha");
awaitexpect(staffChat.locators.headerSubtitle).toHaveText("Group Alpha");
awaitexpect(staffChat.chat().message("our own plan")).toBeVisible();
awaitexpect(staffChat.chat().locators.readOnlyNote).toBeVisible();
awaitexpect(staffChat.chat().locators.composer).toHaveCount(0);
// an observed room is only theirs to read on the page that surfaced it:// leaving it closes the chat and returns them to their own room listawaitnewSideNav(observer.page).goHome();
awaitexpect(staffChat.locators.openChats).toHaveCount(0);
awaitexpect(staffChat.locators.emptyState).toBeVisible();
// the group chats are the staff member's to read, nobody else'sawait participantChat.backToRoomList();
awaitexpect(participantChat.locators.roomRows).toHaveCount(1);
} finally {
await observer.close();
}
test("Staff observer: chats in the match room, reads the group chats", async ({
page,
browser,
workerBaseURL,
factories,
}) => {
const { match, alpha } = awaitcreateMatch(factories);
const staff = await factories.UserFactory.createStaff();
awaitimpersonate(page, alpha[0].id);
awaitnewSendouQMatchPage(page).goto(match.id);
const participantChat = newChatSidebar(page);
await participantChat.selectRoomTab("Group");
await participantChat.chat().send("our own plan");
await participantChat.selectRoomTab("Match");
const observer = awaitopenSecondUser(browser, workerBaseURL, staff.id);
try {
awaitnewSendouQMatchPage(observer.page).goto(match.id);
const staffChat = newChatSidebar(observer.page);
// an observer chats alongside the participants in the match roomawaitexpect(staffChat.locators.openChats).toHaveCount(1);
await staffChat.chat().send("staff here");
awaitexpect(participantChat.chat().message("staff here")).toBeVisible();
await staffChat.backToRoomList();
// the match room they are observing, plus both group chats to readawaitexpect(staffChat.locators.roomRows).toHaveCount(3);
await staffChat.openRoom("Group Alpha");
awaitexpect(staffChat.locators.headerSubtitle).toHaveText("Group Alpha");
awaitexpect(staffChat.chat().message("our own plan")).toBeVisible();
awaitexpect(staffChat.chat().locators.readOnlyNote).toBeVisible();
awaitexpect(staffChat.chat().locators.composer).toHaveCount(0);
// an observed room is only theirs to read on the page that surfaced it:// leaving it closes the chat and returns them to their own room listawaitnewSideNav(observer.page).goHome();
awaitexpect(staffChat.locators.openChats).toHaveCount(0);
awaitexpect(staffChat.locators.emptyState).toBeVisible();
// the group chats are the staff member's to read, nobody else'sawait participantChat.backToRoomList();
awaitexpect(participantChat.locators.roomRows).toHaveCount(1);
} finally {
await observer.close();
}
})
Tournament organizer posts into a match chat and is labelled
try {
awaitnewTournamentMatchPage(participant.page).goto({
tournamentId: tournament.id,
matchId,
});
const participantChat = newChatSidebar(participant.page).chat();
// the tournament's author, and so an observer of the match chatawaitimpersonate(page);
const brackets = newTournamentBracketsPage(page);
await brackets.goto(tournament.id);
const matchPage = await brackets.openMatch(matchId);
const chat = newChatSidebar(page);
awaitexpect(chat.locators.openChats).toHaveCount(1);
// an observer is only pushed the room's messages while on the page that// surfaced it, so what is said from here on never reaches their historyawait matchPage.backToBracket();
await participantChat.send("said while you were on the bracket");
awaitexpect(participantChat.locators.pendingMessages).toHaveCount(0);
await brackets.openMatch(matchId);
awaitexpect(
chat.chat().message("said while you were on the bracket"),
).toBeVisible();
} finally {
await participant.close();
}
Open the tournament match pagetournament idtournament.idmatchId
message “said while you were on the bracket” is visible
awaitexpect(
chat.chat().message("said while you were on the bracket"),
).toBeVisible();
Source
test("An observer coming back to a match chat sees what was said while they were away", async ({
page,
browser,
workerBaseURL,
factories,
}) => {
const { tournament, matchId } = awaitcreateInProgressMatch(factories, {
name: "Chat Cup",
friendId: NZAP_TEST_ID,
});
const participant = awaitopenSecondUser(
browser,
workerBaseURL,
NZAP_TEST_ID,
);
try {
awaitnewTournamentMatchPage(participant.page).goto({
tournamentId: tournament.id,
matchId,
});
const participantChat = newChatSidebar(participant.page).chat();
// the tournament's author, and so an observer of the match chatawaitimpersonate(page);
const brackets = newTournamentBracketsPage(page);
await brackets.goto(tournament.id);
const matchPage = await brackets.openMatch(matchId);
const chat = newChatSidebar(page);
awaitexpect(chat.locators.openChats).toHaveCount(1);
// an observer is only pushed the room's messages while on the page that// surfaced it, so what is said from here on never reaches their historyawait matchPage.backToBracket();
await participantChat.send("said while you were on the bracket");
awaitexpect(participantChat.locators.pendingMessages).toHaveCount(0);
await brackets.openMatch(matchId);
awaitexpect(
chat.chat().message("said while you were on the bracket"),
).toBeVisible();
} finally {
await participant.close();
}
})
A closed room is readable by staff and denied to its participants
Open second userbrowser, workerBaseURL, bravo[0].id→ other
const other = awaitopenSecondUser(browser, workerBaseURL, bravo[0].id);
Try
try {
const matchPage = newSendouQMatchPage(other.page);
await matchPage.goto(match.id);
await matchPage.confirmScore();
awaitexpect(
newChatSidebar(other.page).chat().message(/confirmed score/),
).toBeVisible();
// concluding the match ended both groups and the match room with them,// but its unread system message keeps it listed until it is readawaitexpect(
alphaChat.roomRowUnreadBadge(`Match #${match.id}`),
).toHaveText("1");
awaitexpect(alphaChat.locators.inactiveToggle).toHaveText(
/Inactive \(1\)/,
);
await alphaChat.openRoom(`Match #${match.id}`);
awaitexpect(alphaChat.chat().message(/confirmed score/)).toBeVisible();
await alphaChat.backToRoomList();
awaitexpect(alphaChat.locators.roomRows).toHaveCount(0);
awaitexpect(alphaChat.locators.inactiveToggle).toHaveText(
/Inactive \(2\)/,
);
} finally {
await other.close();
}
test("A system message renders live and moves the rooms to the inactive list", async ({
page,
browser,
workerBaseURL,
factories,
}) => {
const { match, alpha, bravo } = awaitcreateMatch(factories, {
isReported: true,
});
// away from the match page, where the rooms are listed rather than combinedawaitimpersonate(page, alpha[0].id);
awaitnewFrontPage(page).goto();
const alphaChat = newChatSidebar(page);
await alphaChat.open();
// reporting already ended the reporter's group, the match room lives onawaitexpect(alphaChat.roomRow(`Match #${match.id}`)).toBeVisible();
awaitexpect(alphaChat.locators.inactiveToggle).toHaveText(
/Inactive \(1\)/,
);
const other = awaitopenSecondUser(browser, workerBaseURL, bravo[0].id);
try {
const matchPage = newSendouQMatchPage(other.page);
await matchPage.goto(match.id);
await matchPage.confirmScore();
awaitexpect(
newChatSidebar(other.page).chat().message(/confirmed score/),
).toBeVisible();
// concluding the match ended both groups and the match room with them,// but its unread system message keeps it listed until it is readawaitexpect(
alphaChat.roomRowUnreadBadge(`Match #${match.id}`),
).toHaveText("1");
awaitexpect(alphaChat.locators.inactiveToggle).toHaveText(
/Inactive \(1\)/,
);
await alphaChat.openRoom(`Match #${match.id}`);
awaitexpect(alphaChat.chat().message(/confirmed score/)).toBeVisible();
await alphaChat.backToRoomList();
awaitexpect(alphaChat.locators.roomRows).toHaveCount(0);
awaitexpect(alphaChat.locators.inactiveToggle).toHaveText(
/Inactive \(2\)/,
);
} finally {
await other.close();
}
})
Mobile: a chat panel opened before hydration fills in
test("Mobile: the chat waits behind the tab bar, then opens straight into the room", async ({
page,
browser,
workerBaseURL,
factories,
}) => {
const { match, alpha, bravo } = awaitcreateMatch(factories);
await page.setViewportSize(MOBILE_VIEWPORT);
awaitimpersonate(page, bravo[0].id);
awaitnewSendouQMatchPage(page).goto(match.id);
const mobileNav = newMobileNav(page);
const chat = newChatSidebar(page);
// the route's rooms are made active but not opened: the match page keeps// the whole viewport until the user asks for the chatawaitexpect(mobileNav.tab("chat")).toBeVisible();
awaitexpect(chat.locators.openChats).toHaveCount(0);
const other = awaitopenSecondUser(browser, workerBaseURL, alpha[0].id);
try {
awaitnewSendouQMatchPage(other.page).goto(match.id);
awaitnewChatSidebar(other.page).chat().send("on my way");
awaitexpect(mobileNav.tabBadge("chat")).toHaveText("1");
await mobileNav.openPanel("chat");
// straight into the room rather than onto the room listawaitexpect(chat.locators.splitPanels).toHaveCount(2);
awaitexpect(chat.chat().message("on my way")).toBeVisible();
awaitexpect(mobileNav.tabBadge("chat")).toHaveCount(0);
await chat.chat().send("almost there");
awaitexpect(
newChatSidebar(other.page).chat().message("almost there"),
).toBeVisible();
await chat.close();
awaitexpect(chat.locators.openChats).toHaveCount(0);
} finally {
await other.close();
}
})
Tablet: the chat is a modal, and leaving the page or the layout closes it
Open second userbrowser, workerBaseURL, alpha[0].id→ other
const other = awaitopenSecondUser(browser, workerBaseURL, alpha[0].id);
Arrange
Try
try {
awaitnewSendouQMatchPage(other.page).goto(match.id);
awaitnewChatSidebar(other.page).chat().send("tablet ping");
awaitexpect(chat.locators.toggleUnreadBadge).toHaveText("1");
await chat.open();
awaitexpect(chat.locators.modal).toBeVisible();
await chat.openRoom(matchRoom);
awaitexpect(chat.chat().message("tablet ping")).toBeVisible();
// following the room's own link is how a chat modal gets navigated out// of, and Layout closes it on the pathname change — where the desktop// rail deliberately stays openawait chat.followRoomLink();
awaitexpect(page).toHaveURL(sendouQMatchPage(match.id));
awaitexpect(chat.locators.modal).toHaveCount(0);
// the route made its rooms active without opening them, so the modal// comes back on the split viewawait chat.open();
awaitexpect(chat.locators.splitPanels).toHaveCount(2);
await chat.chat().send("tablet pong");
awaitexpect(
newChatSidebar(other.page).chat().message("tablet pong"),
).toBeVisible();
// leaving the tablet range closes the modal too, but the chat stays// open, so the desktop rail takes over with the same roomsawait page.setViewportSize(desktopViewport);
awaitexpect(chat.locators.modal).toHaveCount(0);
awaitexpect(chat.locators.splitPanels).toHaveCount(2);
} finally {
await other.close();
}
following the room's own link is how a chat modal gets navigated out of, and Layout closes it on the pathname change — where the desktop rail deliberately stays open
test("Tablet: the chat is a modal, and leaving the page or the layout closes it", async ({
page,
browser,
workerBaseURL,
factories,
}) => {
const { match, alpha, bravo } = awaitcreateMatch(factories);
const desktopViewport = page.viewportSize()!;
const matchRoom = `Match #${match.id}`;
await page.setViewportSize(TABLET_VIEWPORT);
awaitimpersonate(page, bravo[0].id);
awaitnewFrontPage(page).goto();
const chat = newChatSidebar(page);
awaitexpect(chat.locators.toggleButton).toBeVisible();
awaitexpect(chat.locators.modal).toHaveCount(0);
const other = awaitopenSecondUser(browser, workerBaseURL, alpha[0].id);
try {
awaitnewSendouQMatchPage(other.page).goto(match.id);
awaitnewChatSidebar(other.page).chat().send("tablet ping");
awaitexpect(chat.locators.toggleUnreadBadge).toHaveText("1");
await chat.open();
awaitexpect(chat.locators.modal).toBeVisible();
await chat.openRoom(matchRoom);
awaitexpect(chat.chat().message("tablet ping")).toBeVisible();
// following the room's own link is how a chat modal gets navigated out// of, and Layout closes it on the pathname change — where the desktop// rail deliberately stays openawait chat.followRoomLink();
awaitexpect(page).toHaveURL(sendouQMatchPage(match.id));
awaitexpect(chat.locators.modal).toHaveCount(0);
// the route made its rooms active without opening them, so the modal// comes back on the split viewawait chat.open();
awaitexpect(chat.locators.splitPanels).toHaveCount(2);
await chat.chat().send("tablet pong");
awaitexpect(
newChatSidebar(other.page).chat().message("tablet pong"),
).toBeVisible();
// leaving the tablet range closes the modal too, but the chat stays// open, so the desktop rail takes over with the same roomsawait page.setViewportSize(desktopViewport);
awaitexpect(chat.locators.modal).toHaveCount(0);
awaitexpect(chat.locators.splitPanels).toHaveCount(2);
} finally {
await other.close();
}
})
message(text: string | RegExp) {
returnthis.locators.messages.filter({ hasText: text });
}
ChatSidebare2e/pages/layout/chat-sidebar.ts:8
The chat sidebar and the chats open inside it. The same component renders in the desktop rail, the tablet modal or the mobile panel, never more than one at a time, so the accessors find their elements wherever the viewport put them instead of scoping to a container.
A tournament running an unfinished match: two checked-in teams and a started bracket, which is all it takes for both to be "in a match". The friend and their teammate play the opponent.
asyncopenMatch(matchId: number) {
awaitexpect(async () => {
awaitthis.match(matchId).click();
awaitexpect(
this.page.getByTestId("back-to-bracket-button"),
).toBeVisible();
}).toPass();
// a click that beat hydration loads the match page as a new documentawaitexpectIsHydrated(this.page);
returnnewTournamentMatchPage(this.page);
}
asyncconfirmScore() {
awaitexpect(this.locators.confirmScoreButton).toBeEnabled({
timeout: 10_000,
});
// only a loss needs a second tap; any other first tap already submits, so// the response wait has to be armed before itconst isLoss = (
awaitthis.locators.confirmScoreButton.textContent()
)?.includes("loss");
if (isLoss) {
awaitthis.locators.confirmScoreButton.click();
const armedLossButton = this.locators.confirmScoreButton.filter({
hasText: "Tap again",
// …
holdScriptse2e/helpers/playwright.ts:397
Holds back the page's scripts so that the server rendered DOM can be interacted with the way a user beating hydration to it does. The returned function lets them through, after which the page hydrates as usual.
The bottom tab bar and its panels, rendered in place of the side nav on mobile.
The panels show the same rows as the side nav does, and its accessors find them the same way — scoped to the panel that is open. Every panel is in the DOM, closed, but only the menu's contents are there before it is first opened.