Skip to content

Chat · E2E tests

9 modules · 32 exports · 1 repository · 16 e2e tests · 5 routes

  • NN-ZAP
  • Users a test creates itself are coloured per test
  • Arrange data setup
  • Act what the user does
  • Assert what is expected

Chat

Send and receive: a message crosses between two participants live

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha, bravo }
    const { match, alpha, bravo } = await createMatch(factories);
Aalpha[0]
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
  2. Open second user browser, workerBaseURL, bravo[0].id → other
    const other = await openSecondUser(browser, workerBaseURL, bravo[0].id);
  3. Try
    try {
    	await new SendouQMatchPage(other.page).goto(match.id);
    
    	const alphaChat = new ChatSidebar(page);
    	const bravoChat = new ChatSidebar(other.page);
    
    	await alphaChat.chat().send("gg");
    
    	await expect(bravoChat.chat().message("gg")).toBeVisible();
    	// the optimistic row was reconciled with the persisted one
    	await expect(alphaChat.chat().locators.pendingMessages).toHaveCount(0);
    
    	await bravoChat.chat().send("gg wp");
    
    	await expect(alphaChat.chat().message("gg wp")).toBeVisible();
    } finally {
    	await other.close();
    }
    1. Open the SendouQ match page match.id
      await new SendouQMatchPage(other.page).goto(match.id);
    2. Send “gg”
      await alphaChat.chat().send("gg");
    3. message “gg” is visible
      await expect(bravoChat.chat().message("gg")).toBeVisible();
    4. the optimistic row was reconciled with the persisted one

      pending messages count is 0
      await expect(alphaChat.chat().locators.pendingMessages).toHaveCount(0);
    5. Send “gg wp”
      await bravoChat.chat().send("gg wp");
    6. message “gg wp” is visible
      await expect(alphaChat.chat().message("gg wp")).toBeVisible();
Source
test("Send and receive: a message crosses between two participants live", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { match, alpha, bravo } = await createMatch(factories);

	await impersonate(page, alpha[0].id);
	await new SendouQMatchPage(page).goto(match.id);

	const other = await openSecondUser(browser, workerBaseURL, bravo[0].id);
	try {
		await new SendouQMatchPage(other.page).goto(match.id);

		const alphaChat = new ChatSidebar(page);
		const bravoChat = new ChatSidebar(other.page);

		await alphaChat.chat().send("gg");

		await expect(bravoChat.chat().message("gg")).toBeVisible();
		// the optimistic row was reconciled with the persisted one
		await expect(alphaChat.chat().locators.pendingMessages).toHaveCount(0);

		await bravoChat.chat().send("gg wp");

		await expect(alphaChat.chat().message("gg wp")).toBeVisible();
	} finally {
		await other.close();
	}
})

Sends still in flight when the next one is fired all land

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha }
    const { match, alpha } = await createMatch(factories);
Aalpha[0]
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
  2. long enough that every send below is still unacknowledged when the next fires

    Slow message sends 1000
    await slowMessageSends(page, 1000);
Chat sidebar
Act
  1. Chat → chat
    const chat = new ChatSidebar(page).chat();
  2. sent = ["msg-one", "msg-two", "msg-three"]
    const sent = ["msg-one", "msg-two", "msg-three"];
  3. For each contents of sent
    for (const contents of sent) {
    	await chat.send(contents);
    }
    1. Send contents
      await chat.send(contents);
Chat room
Assert
  1. pending messages count is 0
    await expect(chat.locators.pendingMessages).toHaveCount(0);
  2. messages count is sent.length
    await expect(chat.locators.messages).toHaveCount(sent.length);
  3. For each contents of sent
    for (const contents of sent) {
    	await expect(chat.message(contents)).toBeVisible();
    }
    1. message contents is visible
      await expect(chat.message(contents)).toBeVisible();
  4. text “Error happened” count is 0
    await expect(page.getByText("Error happened")).toHaveCount(0);
Act
  1. the list still takes a send, rather than being wedged by the earlier ones

    Send “msg-four”
    await chat.send("msg-four");
Assert
  1. message “msg-four” is visible
    await expect(chat.message("msg-four")).toBeVisible();
Source
test("Sends still in flight when the next one is fired all land", async ({
	page,
	factories,
}) => {
	const { match, alpha } = await createMatch(factories);

	await impersonate(page, alpha[0].id);
	await new SendouQMatchPage(page).goto(match.id);
	// long enough that every send below is still unacknowledged when the next fires
	await slowMessageSends(page, 1000);

	const chat = new ChatSidebar(page).chat();
	const sent = ["msg-one", "msg-two", "msg-three"];

	for (const contents of sent) {
		await chat.send(contents);
	}

	await expect(chat.locators.pendingMessages).toHaveCount(0);
	await expect(chat.locators.messages).toHaveCount(sent.length);
	for (const contents of sent) {
		await expect(chat.message(contents)).toBeVisible();
	}
	await expect(page.getByText("Error happened")).toHaveCount(0);

	// the list still takes a send, rather than being wedged by the earlier ones
	await chat.send("msg-four");

	await expect(chat.message("msg-four")).toBeVisible();
})

A message sent before the event stream connected still arrives

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha, bravo }
    const { match, alpha, bravo } = await createMatch(factories);
Aalpha[0]
Act
  1. the stream cannot be established while blocked holds, so the page loads with its chat open but nothing listening behind it

    blocked = true
    let blocked = true;
  2. Route /\/sse$/, (route) => blocked ? route.abort() : route.fallback()
    await page.route(/\/sse$/, (route) =>
    	blocked ? route.abort() : route.fallback(),
    );
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
Chat sidebar
Act
  1. Chat → chat
    const chat = new ChatSidebar(page).chat();
Chat room
Assert
  1. the composer being disabled is the page telling us it has no stream

    composer is disabled
    await expect(chat.locators.composer).toBeDisabled();
Act
  1. Open second user browser, workerBaseURL, bravo[0].id → other
    const other = await openSecondUser(browser, workerBaseURL, bravo[0].id);
  2. Try
    try {
    	await new SendouQMatchPage(other.page).goto(match.id);
    	const theirChat = new ChatSidebar(other.page).chat();
    
    	await theirChat.send("sent before you connected");
    	await expect(theirChat.locators.pendingMessages).toHaveCount(0);
    
    	await expect(chat.message("sent before you connected")).toHaveCount(0);
    
    	blocked = false;
    
    	// the stream comes up and the gap it left behind is caught up on
    	await expect(chat.locators.composer).toBeEnabled({ timeout: 15_000 });
    	await expect(chat.message("sent before you connected")).toBeVisible({
    		timeout: 10_000,
    	});
    
    	// and it keeps delivering live from there on
    	await theirChat.send("sent after you connected");
    
    	await expect(chat.message("sent after you connected")).toBeVisible();
    } finally {
    	await other.close();
    }
    1. Open the SendouQ match page match.id
      await new SendouQMatchPage(other.page).goto(match.id);
    2. Chat → theirChat
      const theirChat = new ChatSidebar(other.page).chat();
    3. Send “sent before you connected”
      await theirChat.send("sent before you connected");
    4. pending messages count is 0
      await expect(theirChat.locators.pendingMessages).toHaveCount(0);
    5. message “sent before you connected” count is 0
      await expect(chat.message("sent before you connected")).toHaveCount(0);
    6. blocked = false
      blocked = false;
    7. the stream comes up and the gap it left behind is caught up on

      composer is enabled
      await expect(chat.locators.composer).toBeEnabled({ timeout: 15_000 });
    8. message “sent before you connected” is visible
      await expect(chat.message("sent before you connected")).toBeVisible({
      	timeout: 10_000,
      });
    9. and it keeps delivering live from there on

      Send “sent after you connected”
      await theirChat.send("sent after you connected");
    10. message “sent after you connected” is visible
      await expect(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 } = await createMatch(factories);

	await impersonate(page, alpha[0].id);
	// the stream cannot be established while `blocked` holds, so the page loads
	// with its chat open but nothing listening behind it
	let blocked = true;
	await page.route(/\/sse$/, (route) =>
		blocked ? route.abort() : route.fallback(),
	);
	await new SendouQMatchPage(page).goto(match.id);

	const chat = new ChatSidebar(page).chat();
	// the composer being disabled is the page telling us it has no stream
	await expect(chat.locators.composer).toBeDisabled();

	const other = await openSecondUser(browser, workerBaseURL, bravo[0].id);
	try {
		await new SendouQMatchPage(other.page).goto(match.id);
		const theirChat = new ChatSidebar(other.page).chat();

		await theirChat.send("sent before you connected");
		await expect(theirChat.locators.pendingMessages).toHaveCount(0);

		await expect(chat.message("sent before you connected")).toHaveCount(0);

		blocked = false;

		// the stream comes up and the gap it left behind is caught up on
		await expect(chat.locators.composer).toBeEnabled({ timeout: 15_000 });
		await expect(chat.message("sent before you connected")).toBeVisible({
			timeout: 10_000,
		});

		// and it keeps delivering live from there on
		await theirChat.send("sent after you connected");

		await expect(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

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha }
    const { match, alpha } = await createMatch(factories);
Aalpha[0]
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
Chat sidebar
Assert
  1. the route opens its rooms itself, as tabs while the view is too short to stack them

    split panels count is 2
    await expect(chat.locators.splitPanels).toHaveCount(2);
  2. room tabs reads “Match”, “Group”
    await expect(chat.locators.roomTabs).toHaveText(["Match", "Group"]);
Act
  1. Select room tab “Group”
    await chat.selectRoomTab("Group");
  2. Send “only for my group”
    await chat.chat().send("only for my group");
Assert
  1. message “only for my group” is visible
    await expect(chat.chat().message("only for my group")).toBeVisible();
Act
  1. Select room tab “Match”
    await chat.selectRoomTab("Match");
Assert
  1. message “only for my group” count is 0
    await expect(chat.chat().message("only for my group")).toHaveCount(0);
Arrange
  1. given the height, they stack: match on top, group below

    Use a width 1280 height 1200 viewport
    await page.setViewportSize({ width: 1280, height: 1200 });
Assert
  1. room tabs count is 0
    await expect(chat.locators.roomTabs).toHaveCount(0);
  2. split panel headers reads “Match”, “Group”
    await expect(chat.locators.splitPanelHeaders).toHaveText([
    	"Match",
    	"Group",
    ]);
  3. message “only for my group” is visible
    await expect(chat.chat(1).message("only for my group")).toBeVisible();
  4. message “only for my group” count is 0
    await expect(chat.chat(0).message("only for my group")).toHaveCount(0);
Act
  1. Back to room list
    await chat.backToRoomList();
Assert
  1. the two rooms the route opened are one combined row, and there is no third

    room rows count is 1
    await expect(chat.locators.roomRows).toHaveCount(1);
  2. room rows contains “Match · Group”
    await expect(chat.locators.roomRows).toContainText("Match · Group");
Source
test("Split view: a participant gets the match and their own group chat, never the opponent's", async ({
	page,
	factories,
}) => {
	const { match, alpha } = await createMatch(factories);

	await impersonate(page, alpha[0].id);
	await new SendouQMatchPage(page).goto(match.id);

	const chat = new ChatSidebar(page);

	// the route opens its rooms itself, as tabs while the view is too short to stack them
	await expect(chat.locators.splitPanels).toHaveCount(2);
	await expect(chat.locators.roomTabs).toHaveText(["Match", "Group"]);

	await chat.selectRoomTab("Group");
	await chat.chat().send("only for my group");

	await expect(chat.chat().message("only for my group")).toBeVisible();

	await chat.selectRoomTab("Match");

	await expect(chat.chat().message("only for my group")).toHaveCount(0);

	// given the height, they stack: match on top, group below
	await page.setViewportSize({ width: 1280, height: 1200 });

	await expect(chat.locators.roomTabs).toHaveCount(0);
	await expect(chat.locators.splitPanelHeaders).toHaveText([
		"Match",
		"Group",
	]);
	await expect(chat.chat(1).message("only for my group")).toBeVisible();
	await expect(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 third
	await expect(chat.locators.roomRows).toHaveCount(1);
	await expect(chat.locators.roomRows).toContainText("Match · Group");
})

Unread badge: counts messages arriving while the sidebar is closed and clears on open

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha, bravo }
    const { match, alpha, bravo } = await createMatch(factories);
  2. matchRoom = `Match #${match.id}`
    const matchRoom = `Match #${match.id}`;
Aalpha[0]
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
  2. Open second user browser, workerBaseURL, bravo[0].id → other
    const other = await openSecondUser(browser, workerBaseURL, bravo[0].id);
  3. Try
    try {
    	// away from the match page, so nothing opens the room for them
    	await new FrontPage(other.page).goto();
    
    	const alphaChat = new ChatSidebar(page);
    	const bravoChat = new ChatSidebar(other.page);
    
    	await alphaChat.chat().send("one");
    	await expect(alphaChat.chat().message("one")).toBeVisible();
    	await alphaChat.chat().send("two");
    
    	await expect(bravoChat.locators.toggleUnreadBadge).toHaveText("2");
    
    	await bravoChat.open();
    
    	await expect(bravoChat.roomRowUnreadBadge(matchRoom)).toHaveText("2");
    
    	await bravoChat.openRoom(matchRoom);
    
    	await expect(bravoChat.chat().message("two")).toBeVisible();
    
    	await bravoChat.backToRoomList();
    
    	await expect(bravoChat.roomRow(matchRoom)).toBeVisible();
    	await expect(bravoChat.roomRowUnreadBadge(matchRoom)).toHaveCount(0);
    } finally {
    	await other.close();
    }
    1. away from the match page, so nothing opens the room for them

      Open the front page
      await new FrontPage(other.page).goto();
    2. Send “one”
      await alphaChat.chat().send("one");
    3. message “one” is visible
      await expect(alphaChat.chat().message("one")).toBeVisible();
    4. Send “two”
      await alphaChat.chat().send("two");
    5. toggle unread badge reads “2”
      await expect(bravoChat.locators.toggleUnreadBadge).toHaveText("2");
    6. Open
      await bravoChat.open();
    7. room row unread badge matchRoom reads “2”
      await expect(bravoChat.roomRowUnreadBadge(matchRoom)).toHaveText("2");
    8. Open room matchRoom
      await bravoChat.openRoom(matchRoom);
    9. message “two” is visible
      await expect(bravoChat.chat().message("two")).toBeVisible();
    10. Back to room list
      await bravoChat.backToRoomList();
    11. room row matchRoom is visible
      await expect(bravoChat.roomRow(matchRoom)).toBeVisible();
    12. room row unread badge matchRoom count is 0
      await expect(bravoChat.roomRowUnreadBadge(matchRoom)).toHaveCount(0);
Source
test("Unread badge: counts messages arriving while the sidebar is closed and clears on open", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { match, alpha, bravo } = await createMatch(factories);
	const matchRoom = `Match #${match.id}`;

	await impersonate(page, alpha[0].id);
	await new SendouQMatchPage(page).goto(match.id);

	const other = await openSecondUser(browser, workerBaseURL, bravo[0].id);
	try {
		// away from the match page, so nothing opens the room for them
		await new FrontPage(other.page).goto();

		const alphaChat = new ChatSidebar(page);
		const bravoChat = new ChatSidebar(other.page);

		await alphaChat.chat().send("one");
		await expect(alphaChat.chat().message("one")).toBeVisible();
		await alphaChat.chat().send("two");

		await expect(bravoChat.locators.toggleUnreadBadge).toHaveText("2");

		await bravoChat.open();

		await expect(bravoChat.roomRowUnreadBadge(matchRoom)).toHaveText("2");

		await bravoChat.openRoom(matchRoom);

		await expect(bravoChat.chat().message("two")).toBeVisible();

		await bravoChat.backToRoomList();

		await expect(bravoChat.roomRow(matchRoom)).toBeVisible();
		await expect(bravoChat.roomRowUnreadBadge(matchRoom)).toHaveCount(0);
	} finally {
		await other.close();
	}
})

Read state follows the user, not the device they read on

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha, bravo }
    const { match, alpha, bravo } = await createMatch(factories);
  2. matchRoom = `Match #${match.id}`
    const matchRoom = `Match #${match.id}`;
Aalpha[0]
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
  2. Open second user browser, workerBaseURL, bravo[0].id → phone
    const phone = await openSecondUser(browser, workerBaseURL, bravo[0].id);
  3. Open second user browser, workerBaseURL, bravo[0].id → laptop
    const laptop = await openSecondUser(browser, workerBaseURL, bravo[0].id);
  4. Try
    try {
    	await new FrontPage(phone.page).goto();
    	await new FrontPage(laptop.page).goto();
    
    	const phoneChat = new ChatSidebar(phone.page);
    	const laptopChat = new ChatSidebar(laptop.page);
    
    	await new ChatSidebar(page).chat().send("read me");
    
    	await expect(phoneChat.locators.toggleUnreadBadge).toHaveText("1");
    	await expect(laptopChat.locators.toggleUnreadBadge).toHaveText("1");
    
    	const readPosts: string[] = [];
    	phone.page.on("request", (request) => {
    		if (
    			request.method() === "POST" &&
    			/\/chat\/rooms\/\d+\/read$/.test(new URL(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 it
    	expect(readPosts).toHaveLength(0);
    	await new FrontPage(phone.page).goto();
    
    	await new FrontPage(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 yet
    	await expect(laptopChat.roomRow(matchRoom)).toBeVisible();
    	await expect(laptopChat.roomRowUnreadBadge(matchRoom)).toHaveCount(0);
    } finally {
    	await Promise.all([phone.close(), laptop.close()]);
    }
    1. Open the front page
      await new FrontPage(phone.page).goto();
    2. Open the front page
      await new FrontPage(laptop.page).goto();
    3. Send “read me”
      await new ChatSidebar(page).chat().send("read me");
    4. toggle unread badge reads “1”
      await expect(phoneChat.locators.toggleUnreadBadge).toHaveText("1");
    5. toggle unread badge reads “1”
      await expect(laptopChat.locators.toggleUnreadBadge).toHaveText("1");
    6. readPosts: string[] = []
      const readPosts: string[] = [];
    7. On “request”, (request) => { if ( request.method() === "POST" && /\/chat\/rooms\/\d+\/read$…
      phone.page.on("request", (request) => {
      	if (
      		request.method() === "POST" &&
      		/\/chat\/rooms\/\d+\/read$/.test(new URL(request.url()).pathname)
      	) {
      		readPosts.push(request.url());
      	}
      });
    8. Open
      await phoneChat.open();
    9. Open room matchRoom
      await phoneChat.openRoom(matchRoom);
    10. the read indicator is posted debounced, so leaving the page this soon after reading only tells the server if the unload flushes it

      readPosts has 0 items
      expect(readPosts).toHaveLength(0);
    11. Open the front page
      await new FrontPage(phone.page).goto();
    12. Open the front page
      await new FrontPage(laptop.page).goto();
    13. Open
      await laptopChat.open();
    14. 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 yet

      room row matchRoom is visible
      await expect(laptopChat.roomRow(matchRoom)).toBeVisible();
    15. room row unread badge matchRoom count is 0
      await expect(laptopChat.roomRowUnreadBadge(matchRoom)).toHaveCount(0);
Source
test("Read state follows the user, not the device they read on", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { match, alpha, bravo } = await createMatch(factories);
	const matchRoom = `Match #${match.id}`;

	await impersonate(page, alpha[0].id);
	await new SendouQMatchPage(page).goto(match.id);

	const phone = await openSecondUser(browser, workerBaseURL, bravo[0].id);
	const laptop = await openSecondUser(browser, workerBaseURL, bravo[0].id);
	try {
		await new FrontPage(phone.page).goto();
		await new FrontPage(laptop.page).goto();

		const phoneChat = new ChatSidebar(phone.page);
		const laptopChat = new ChatSidebar(laptop.page);

		await new ChatSidebar(page).chat().send("read me");

		await expect(phoneChat.locators.toggleUnreadBadge).toHaveText("1");
		await expect(laptopChat.locators.toggleUnreadBadge).toHaveText("1");

		const readPosts: string[] = [];
		phone.page.on("request", (request) => {
			if (
				request.method() === "POST" &&
				/\/chat\/rooms\/\d+\/read$/.test(new URL(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 it
		expect(readPosts).toHaveLength(0);
		await new FrontPage(phone.page).goto();

		await new FrontPage(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 yet
		await expect(laptopChat.roomRow(matchRoom)).toBeVisible();
		await expect(laptopChat.roomRowUnreadBadge(matchRoom)).toHaveCount(0);
	} finally {
		await Promise.all([phone.close(), laptop.close()]);
	}
})

Leaving a group takes its chat out of the sidebar without a reload

Renders/q/q/looking

Setup
Arrange
  1. Create 2 × user → [owner, member]
    const [owner, member] = await factories.UserFactory.createMany(2);
  2. Create SQ group member user ids owner.id, member.id
    await factories.SQGroupFactory.create({
    	memberUserIds: [owner.id, member.id],
    });
Mmember
SendouQ looking page
Act
  1. Open the SendouQ looking page
    await looking.goto();
Chat sidebar
Assert
  1. open chats count is 1
    await expect(chat.locators.openChats).toHaveCount(1);
Act
  1. Fetch chat rooms → [groupRoom]
    const [groupRoom] = await fetchChatRooms(page);
SendouQ looking page
Act
  1. Leave group
    await looking.leaveGroup();
Chat sidebar
Assert
  1. open chats count is 0
    await expect(chat.locators.openChats).toHaveCount(0);
  2. room rows count is 0
    await expect(chat.locators.roomRows).toHaveCount(0);
  3. empty state is visible
    await expect(chat.locators.emptyState).toBeVisible();
  4. and no reading the group's messages from outside it either

    chatHistoryStatus(page, groupRoom.id) is 403
    expect(await chatHistoryStatus(page, groupRoom.id)).toBe(403);
Source
test("Leaving a group takes its chat out of the sidebar without a reload", async ({
	page,
	factories,
}) => {
	const [owner, member] = await factories.UserFactory.createMany(2);
	await factories.SQGroupFactory.create({
		memberUserIds: [owner.id, member.id],
	});

	await impersonate(page, member.id);
	const looking = new SendouQLookingPage(page);
	await looking.goto();

	const chat = new ChatSidebar(page);
	await expect(chat.locators.openChats).toHaveCount(1);

	const [groupRoom] = await fetchChatRooms(page);

	await looking.leaveGroup();

	await expect(chat.locators.openChats).toHaveCount(0);
	await expect(chat.locators.roomRows).toHaveCount(0);
	await expect(chat.locators.emptyState).toBeVisible();

	// and no reading the group's messages from outside it either
	expect(await chatHistoryStatus(page, groupRoom.id)).toBe(403);
})

Staff observer: chats in the match room, reads the group chats

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha }
    const { match, alpha } = await createMatch(factories);
  2. Create staff user → staff
    const staff = await factories.UserFactory.createStaff();
Aalpha[0]
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
Chat sidebar
Act
  1. Select room tab “Group”
    await participantChat.selectRoomTab("Group");
  2. Send “our own plan”
    await participantChat.chat().send("our own plan");
  3. Select room tab “Match”
    await participantChat.selectRoomTab("Match");
  4. Open second user browser, workerBaseURL, staff.id → observer
    const observer = await openSecondUser(browser, workerBaseURL, staff.id);
  5. Try
    try {
    	await new SendouQMatchPage(observer.page).goto(match.id);
    
    	const staffChat = new ChatSidebar(observer.page);
    
    	// an observer chats alongside the participants in the match room
    	await expect(staffChat.locators.openChats).toHaveCount(1);
    	await staffChat.chat().send("staff here");
    
    	await expect(participantChat.chat().message("staff here")).toBeVisible();
    
    	await staffChat.backToRoomList();
    
    	// the match room they are observing, plus both group chats to read
    	await expect(staffChat.locators.roomRows).toHaveCount(3);
    
    	await staffChat.openRoom("Group Alpha");
    
    	await expect(staffChat.locators.headerSubtitle).toHaveText("Group Alpha");
    	await expect(staffChat.chat().message("our own plan")).toBeVisible();
    	await expect(staffChat.chat().locators.readOnlyNote).toBeVisible();
    	await expect(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 list
    	await new SideNav(observer.page).goHome();
    
    	await expect(staffChat.locators.openChats).toHaveCount(0);
    	await expect(staffChat.locators.emptyState).toBeVisible();
    
    	// the group chats are the staff member's to read, nobody else's
    	await participantChat.backToRoomList();
    	await expect(participantChat.locators.roomRows).toHaveCount(1);
    } finally {
    	await observer.close();
    }
    1. Open the SendouQ match page match.id
      await new SendouQMatchPage(observer.page).goto(match.id);
    2. an observer chats alongside the participants in the match room

      open chats count is 1
      await expect(staffChat.locators.openChats).toHaveCount(1);
    3. Send “staff here”
      await staffChat.chat().send("staff here");
    4. message “staff here” is visible
      await expect(participantChat.chat().message("staff here")).toBeVisible();
    5. Back to room list
      await staffChat.backToRoomList();
    6. the match room they are observing, plus both group chats to read

      room rows count is 3
      await expect(staffChat.locators.roomRows).toHaveCount(3);
    7. Open room “Group Alpha”
      await staffChat.openRoom("Group Alpha");
    8. header subtitle reads “Group Alpha”
      await expect(staffChat.locators.headerSubtitle).toHaveText("Group Alpha");
    9. message “our own plan” is visible
      await expect(staffChat.chat().message("our own plan")).toBeVisible();
    10. read only note is visible
      await expect(staffChat.chat().locators.readOnlyNote).toBeVisible();
    11. composer count is 0
      await expect(staffChat.chat().locators.composer).toHaveCount(0);
    12. 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 list

      Go home
      await new SideNav(observer.page).goHome();
    13. open chats count is 0
      await expect(staffChat.locators.openChats).toHaveCount(0);
    14. empty state is visible
      await expect(staffChat.locators.emptyState).toBeVisible();
    15. the group chats are the staff member's to read, nobody else's

      Back to room list
      await participantChat.backToRoomList();
    16. room rows count is 1
      await expect(participantChat.locators.roomRows).toHaveCount(1);
Source
test("Staff observer: chats in the match room, reads the group chats", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { match, alpha } = await createMatch(factories);
	const staff = await factories.UserFactory.createStaff();

	await impersonate(page, alpha[0].id);
	await new SendouQMatchPage(page).goto(match.id);

	const participantChat = new ChatSidebar(page);
	await participantChat.selectRoomTab("Group");
	await participantChat.chat().send("our own plan");
	await participantChat.selectRoomTab("Match");

	const observer = await openSecondUser(browser, workerBaseURL, staff.id);
	try {
		await new SendouQMatchPage(observer.page).goto(match.id);

		const staffChat = new ChatSidebar(observer.page);

		// an observer chats alongside the participants in the match room
		await expect(staffChat.locators.openChats).toHaveCount(1);
		await staffChat.chat().send("staff here");

		await expect(participantChat.chat().message("staff here")).toBeVisible();

		await staffChat.backToRoomList();

		// the match room they are observing, plus both group chats to read
		await expect(staffChat.locators.roomRows).toHaveCount(3);

		await staffChat.openRoom("Group Alpha");

		await expect(staffChat.locators.headerSubtitle).toHaveText("Group Alpha");
		await expect(staffChat.chat().message("our own plan")).toBeVisible();
		await expect(staffChat.chat().locators.readOnlyNote).toBeVisible();
		await expect(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 list
		await new SideNav(observer.page).goHome();

		await expect(staffChat.locators.openChats).toHaveCount(0);
		await expect(staffChat.locators.emptyState).toBeVisible();

		// the group chats are the staff member's to read, nobody else's
		await participantChat.backToRoomList();
		await expect(participantChat.locators.roomRows).toHaveCount(1);
	} finally {
		await observer.close();
	}
})

Tournament organizer posts into a match chat and is labelled

Renders/to/:id/to/:id/matches/:mid

Setup
Arrange
  1. Create in progress match name “Chat Cup” friend N-ZAP → { tournament, matchId }
    const { tournament, matchId } = await createInProgressMatch(factories, {
    	name: "Chat Cup",
    	friendId: NZAP_TEST_ID,
    });
NN-ZAP
Tournament match page
Act
  1. Open the tournament match page tournament id tournament.id matchId
    await matchPage.goto({ tournamentId: tournament.id, matchId });
  2. the tournament's author, and so its organizer

    Open second user browser, workerBaseURL → organizer
    const organizer = await openSecondUser(browser, workerBaseURL);
  3. Try
    try {
    	await new TournamentMatchPage(organizer.page).goto({
    		tournamentId: tournament.id,
    		matchId,
    	});
    
    	const organizerChat = new ChatSidebar(organizer.page);
    	await organizerChat.chat().send("start when ready");
    
    	const participantChat = new ChatSidebar(page);
    	await expect(
    		participantChat.chat().message("start when ready"),
    	).toBeVisible();
    	await expect(participantChat.chat().authorLabel("TO")).toBeVisible();
    } finally {
    	await organizer.close();
    }
    1. Open the tournament match page tournament id tournament.id matchId
      await new TournamentMatchPage(organizer.page).goto({
      	tournamentId: tournament.id,
      	matchId,
      });
    2. Send “start when ready”
      await organizerChat.chat().send("start when ready");
    3. message “start when ready” is visible
      await expect(
      	participantChat.chat().message("start when ready"),
      ).toBeVisible();
    4. author label “TO” is visible
      await expect(participantChat.chat().authorLabel("TO")).toBeVisible();
Source
test("Tournament organizer posts into a match chat and is labelled", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { tournament, matchId } = await createInProgressMatch(factories, {
		name: "Chat Cup",
		friendId: NZAP_TEST_ID,
	});

	await impersonate(page, NZAP_TEST_ID);
	const matchPage = new TournamentMatchPage(page);
	await matchPage.goto({ tournamentId: tournament.id, matchId });

	// the tournament's author, and so its organizer
	const organizer = await openSecondUser(browser, workerBaseURL);
	try {
		await new TournamentMatchPage(organizer.page).goto({
			tournamentId: tournament.id,
			matchId,
		});

		const organizerChat = new ChatSidebar(organizer.page);
		await organizerChat.chat().send("start when ready");

		const participantChat = new ChatSidebar(page);
		await expect(
			participantChat.chat().message("start when ready"),
		).toBeVisible();
		await expect(participantChat.chat().authorLabel("TO")).toBeVisible();
	} finally {
		await organizer.close();
	}
})

An observer coming back to a match chat sees what was said while they were away

Renders/to/:id/to/:id/brackets/to/:id/matches/:mid

Setup
Arrange
  1. Create in progress match name “Chat Cup” friend N-ZAP → { tournament, matchId }
    const { tournament, matchId } = await createInProgressMatch(factories, {
    	name: "Chat Cup",
    	friendId: NZAP_TEST_ID,
    });
LOLogged out
Act
  1. Open second user browser, workerBaseURL, N-ZAP → participant
    const participant = await openSecondUser(
    	browser,
    	workerBaseURL,
    	NZAP_TEST_ID,
    );
  2. Try
    try {
    	await new TournamentMatchPage(participant.page).goto({
    		tournamentId: tournament.id,
    		matchId,
    	});
    	const participantChat = new ChatSidebar(participant.page).chat();
    
    	// the tournament's author, and so an observer of the match chat
    	await impersonate(page);
    	const brackets = new TournamentBracketsPage(page);
    	await brackets.goto(tournament.id);
    
    	const matchPage = await brackets.openMatch(matchId);
    	const chat = new ChatSidebar(page);
    
    	await expect(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 history
    	await matchPage.backToBracket();
    
    	await participantChat.send("said while you were on the bracket");
    	await expect(participantChat.locators.pendingMessages).toHaveCount(0);
    
    	await brackets.openMatch(matchId);
    
    	await expect(
    		chat.chat().message("said while you were on the bracket"),
    	).toBeVisible();
    } finally {
    	await participant.close();
    }
    1. Open the tournament match page tournament id tournament.id matchId
      await new TournamentMatchPage(participant.page).goto({
      	tournamentId: tournament.id,
      	matchId,
      });
    2. Chat → participantChat
      const participantChat = new ChatSidebar(participant.page).chat();
    3. the tournament's author, and so an observer of the match chat

      await impersonate(page);
    4. Open the tournament brackets page tournament.id
      await brackets.goto(tournament.id);
    5. Open match matchId → matchPage
      const matchPage = await brackets.openMatch(matchId);
    6. open chats count is 1
      await expect(chat.locators.openChats).toHaveCount(1);
    7. 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 history

      Back to bracket
      await matchPage.backToBracket();
    8. Send “said while you were on the bracket”
      await participantChat.send("said while you were on the bracket");
    9. pending messages count is 0
      await expect(participantChat.locators.pendingMessages).toHaveCount(0);
    10. Open match matchId
      await brackets.openMatch(matchId);
    11. message “said while you were on the bracket” is visible
      await expect(
      	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 } = await createInProgressMatch(factories, {
		name: "Chat Cup",
		friendId: NZAP_TEST_ID,
	});

	const participant = await openSecondUser(
		browser,
		workerBaseURL,
		NZAP_TEST_ID,
	);
	try {
		await new TournamentMatchPage(participant.page).goto({
			tournamentId: tournament.id,
			matchId,
		});
		const participantChat = new ChatSidebar(participant.page).chat();

		// the tournament's author, and so an observer of the match chat
		await impersonate(page);
		const brackets = new TournamentBracketsPage(page);
		await brackets.goto(tournament.id);

		const matchPage = await brackets.openMatch(matchId);
		const chat = new ChatSidebar(page);

		await expect(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 history
		await matchPage.backToBracket();

		await participantChat.send("said while you were on the bracket");
		await expect(participantChat.locators.pendingMessages).toHaveCount(0);

		await brackets.openMatch(matchId);

		await expect(
			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

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha }
    const { match, alpha } = await createMatch(factories);
  2. Create staff user → staff
    const staff = await factories.UserFactory.createStaff();
LOLogged out
Act
  1. Invariant match.chatRoomId, “Match was created without a chat room”
    invariant(match.chatRoomId, "Match was created without a chat room");
Aalpha[0]
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
Chat sidebar
Act
  1. Send “before the room closed”
    await participantChat.chat().send("before the room closed");
Assert
  1. the message has to land before the room closes under it

    pending messages count is 0
    await expect(participantChat.chat().locators.pendingMessages).toHaveCount(
    	0,
    );
Arrange
  1. Backdate “ChatRoom” match.chatRoomId expires at sub(new Date(), { months: 2 })
    await factories.backdate("ChatRoom", match.chatRoomId, {
    	expiresAt: sub(new Date(), { months: 2 }),
    });
Act
  1. Run routine “CloseExpiredChatRooms”
    await runRoutine(page, "CloseExpiredChatRooms");
Assert
  1. chatHistoryStatus(page, match.chatRoomId) is 403
    expect(await chatHistoryStatus(page, match.chatRoomId)).toBe(403);
Act
  1. Open second user browser, workerBaseURL, staff.id → observer
    const observer = await openSecondUser(browser, workerBaseURL, staff.id);
  2. Try
    try {
    	await new SendouQMatchPage(observer.page).goto(match.id);
    
    	const staffChat = new ChatSidebar(observer.page);
    
    	await expect(
    		staffChat.chat().message("before the room closed"),
    	).toBeVisible();
    	await expect(staffChat.chat().locators.composer).toHaveCount(0);
    } finally {
    	await observer.close();
    }
    1. Open the SendouQ match page match.id
      await new SendouQMatchPage(observer.page).goto(match.id);
    2. message “before the room closed” is visible
      await expect(
      	staffChat.chat().message("before the room closed"),
      ).toBeVisible();
    3. composer count is 0
      await expect(staffChat.chat().locators.composer).toHaveCount(0);
Source
test("A closed room is readable by staff and denied to its participants", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { match, alpha } = await createMatch(factories);
	const staff = await factories.UserFactory.createStaff();
	invariant(match.chatRoomId, "Match was created without a chat room");

	await impersonate(page, alpha[0].id);
	await new SendouQMatchPage(page).goto(match.id);
	const participantChat = new ChatSidebar(page);
	await participantChat.chat().send("before the room closed");
	// the message has to land before the room closes under it
	await expect(participantChat.chat().locators.pendingMessages).toHaveCount(
		0,
	);

	await factories.backdate("ChatRoom", match.chatRoomId, {
		expiresAt: sub(new Date(), { months: 2 }),
	});
	await runRoutine(page, "CloseExpiredChatRooms");

	expect(await chatHistoryStatus(page, match.chatRoomId)).toBe(403);

	const observer = await openSecondUser(browser, workerBaseURL, staff.id);
	try {
		await new SendouQMatchPage(observer.page).goto(match.id);

		const staffChat = new ChatSidebar(observer.page);

		await expect(
			staffChat.chat().message("before the room closed"),
		).toBeVisible();
		await expect(staffChat.chat().locators.composer).toHaveCount(0);
	} finally {
		await observer.close();
	}
})

An observer's message counts as unread away from the room's page

Renders/

Setup
Arrange
  1. Create match → { match, alpha }
    const { match, alpha } = await createMatch(factories);
  2. Create staff user → staff
    const staff = await factories.UserFactory.createStaff();
Aalpha[0]
Front page
Act
  1. Open the front page
    await new FrontPage(page).goto();
Chat sidebar
Assert
  1. toggle button is visible
    await expect(chat.locators.toggleButton).toBeVisible();
Act
  1. Open second user browser, workerBaseURL, staff.id → observer
    const observer = await openSecondUser(browser, workerBaseURL, staff.id);
  2. Try
    try {
    	await new SendouQMatchPage(observer.page).goto(match.id);
    	await new ChatSidebar(observer.page).chat().send("staff ping");
    
    	await expect(chat.locators.toggleUnreadBadge).toHaveText("1");
    } finally {
    	await observer.close();
    }
    1. Open the SendouQ match page match.id
      await new SendouQMatchPage(observer.page).goto(match.id);
    2. Send “staff ping”
      await new ChatSidebar(observer.page).chat().send("staff ping");
    3. toggle unread badge reads “1”
      await expect(chat.locators.toggleUnreadBadge).toHaveText("1");
Source
test("An observer's message counts as unread away from the room's page", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { match, alpha } = await createMatch(factories);
	const staff = await factories.UserFactory.createStaff();

	// away from the match page, with the chat closed: only the toggle's badge
	// tells the participant that anything was said
	await impersonate(page, alpha[0].id);
	await new FrontPage(page).goto();

	const chat = new ChatSidebar(page);
	await expect(chat.locators.toggleButton).toBeVisible();

	const observer = await openSecondUser(browser, workerBaseURL, staff.id);
	try {
		await new SendouQMatchPage(observer.page).goto(match.id);
		await new ChatSidebar(observer.page).chat().send("staff ping");

		await expect(chat.locators.toggleUnreadBadge).toHaveText("1");
	} finally {
		await observer.close();
	}
})

A system message renders live and moves the rooms to the inactive list

Renders/

Setup
Arrange
  1. Create match is reported true → { match, alpha, bravo }
    const { match, alpha, bravo } = await createMatch(factories, {
    	isReported: true,
    });
Aalpha[0]
Front page
Act
  1. Open the front page
    await new FrontPage(page).goto();
Chat sidebar
Act
  1. Open
    await alphaChat.open();
Assert
  1. reporting already ended the reporter's group, the match room lives on

    room row `Match #${match.id}` is visible
    await expect(alphaChat.roomRow(`Match #${match.id}`)).toBeVisible();
  2. inactive toggle reads /Inactive \(1\)/
    await expect(alphaChat.locators.inactiveToggle).toHaveText(
    	/Inactive \(1\)/,
    );
Act
  1. Open second user browser, workerBaseURL, bravo[0].id → other
    const other = await openSecondUser(browser, workerBaseURL, bravo[0].id);
  2. Try
    try {
    	const matchPage = new SendouQMatchPage(other.page);
    	await matchPage.goto(match.id);
    	await matchPage.confirmScore();
    
    	await expect(
    		new ChatSidebar(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 read
    	await expect(
    		alphaChat.roomRowUnreadBadge(`Match #${match.id}`),
    	).toHaveText("1");
    	await expect(alphaChat.locators.inactiveToggle).toHaveText(
    		/Inactive \(1\)/,
    	);
    
    	await alphaChat.openRoom(`Match #${match.id}`);
    
    	await expect(alphaChat.chat().message(/confirmed score/)).toBeVisible();
    
    	await alphaChat.backToRoomList();
    
    	await expect(alphaChat.locators.roomRows).toHaveCount(0);
    	await expect(alphaChat.locators.inactiveToggle).toHaveText(
    		/Inactive \(2\)/,
    	);
    } finally {
    	await other.close();
    }
    1. Open the SendouQ match page match.id
      await matchPage.goto(match.id);
    2. Confirm score
      await matchPage.confirmScore();
    3. message /confirmed score/ is visible
      await expect(
      	new ChatSidebar(other.page).chat().message(/confirmed score/),
      ).toBeVisible();
    4. concluding the match ended both groups and the match room with them, but its unread system message keeps it listed until it is read

      room row unread badge `Match #${match.id}` reads “1”
      await expect(
      	alphaChat.roomRowUnreadBadge(`Match #${match.id}`),
      ).toHaveText("1");
    5. inactive toggle reads /Inactive \(1\)/
      await expect(alphaChat.locators.inactiveToggle).toHaveText(
      	/Inactive \(1\)/,
      );
    6. Open room `Match #${match.id}`
      await alphaChat.openRoom(`Match #${match.id}`);
    7. message /confirmed score/ is visible
      await expect(alphaChat.chat().message(/confirmed score/)).toBeVisible();
    8. Back to room list
      await alphaChat.backToRoomList();
    9. room rows count is 0
      await expect(alphaChat.locators.roomRows).toHaveCount(0);
    10. inactive toggle reads /Inactive \(2\)/
      await expect(alphaChat.locators.inactiveToggle).toHaveText(
      	/Inactive \(2\)/,
      );
Source
test("A system message renders live and moves the rooms to the inactive list", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { match, alpha, bravo } = await createMatch(factories, {
		isReported: true,
	});

	// away from the match page, where the rooms are listed rather than combined
	await impersonate(page, alpha[0].id);
	await new FrontPage(page).goto();

	const alphaChat = new ChatSidebar(page);
	await alphaChat.open();

	// reporting already ended the reporter's group, the match room lives on
	await expect(alphaChat.roomRow(`Match #${match.id}`)).toBeVisible();
	await expect(alphaChat.locators.inactiveToggle).toHaveText(
		/Inactive \(1\)/,
	);

	const other = await openSecondUser(browser, workerBaseURL, bravo[0].id);
	try {
		const matchPage = new SendouQMatchPage(other.page);
		await matchPage.goto(match.id);
		await matchPage.confirmScore();

		await expect(
			new ChatSidebar(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 read
		await expect(
			alphaChat.roomRowUnreadBadge(`Match #${match.id}`),
		).toHaveText("1");
		await expect(alphaChat.locators.inactiveToggle).toHaveText(
			/Inactive \(1\)/,
		);

		await alphaChat.openRoom(`Match #${match.id}`);

		await expect(alphaChat.chat().message(/confirmed score/)).toBeVisible();

		await alphaChat.backToRoomList();

		await expect(alphaChat.locators.roomRows).toHaveCount(0);
		await expect(alphaChat.locators.inactiveToggle).toHaveText(
			/Inactive \(2\)/,
		);
	} finally {
		await other.close();
	}
})

Mobile: a chat panel opened before hydration fills in

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, bravo }
    const { match, bravo } = await createMatch(factories);
  2. Use a MOBILE_VIEWPORT viewport
    await page.setViewportSize(MOBILE_VIEWPORT);
Bbravo[0]
Act
  1. Hold scripts → releaseScripts
    const releaseScripts = await holdScripts(page);
  2. Go to sendouQMatchPage(match.id)
    await page.goto(sendouQMatchPage(match.id), {
    	waitUntil: "domcontentloaded",
    });
Mobile nav
Act
  1. Open panel “chat”
    await mobileNav.openPanel("chat");
  2. Release scripts
    releaseScripts();
Chat sidebar
Assert
  1. the panel the user opened without waiting is adopted rather than left empty

    first open chats is visible
    await expect(
    	new ChatSidebar(page).locators.openChats.first(),
    ).toBeVisible();
Mobile nav
Assert
  1. tab “chat” has “data-active” = “true”
    await expect(mobileNav.tab("chat")).toHaveAttribute("data-active", "true");
Source
test("Mobile: a chat panel opened before hydration fills in", async ({
	page,
	factories,
}) => {
	const { match, bravo } = await createMatch(factories);

	await page.setViewportSize(MOBILE_VIEWPORT);
	await impersonate(page, bravo[0].id);

	const releaseScripts = await holdScripts(page);
	await page.goto(sendouQMatchPage(match.id), {
		waitUntil: "domcontentloaded",
	});

	const mobileNav = new MobileNav(page);
	await mobileNav.openPanel("chat");

	releaseScripts();
	await expectIsHydrated(page);

	// the panel the user opened without waiting is adopted rather than left empty
	await expect(
		new ChatSidebar(page).locators.openChats.first(),
	).toBeVisible();
	await expect(mobileNav.tab("chat")).toHaveAttribute("data-active", "true");
})

Mobile: the chat waits behind the tab bar, then opens straight into the room

Renders/q/match/:id

Setup
Arrange
  1. Create match → { match, alpha, bravo }
    const { match, alpha, bravo } = await createMatch(factories);
  2. Use a MOBILE_VIEWPORT viewport
    await page.setViewportSize(MOBILE_VIEWPORT);
Bbravo[0]
SendouQ match page
Act
  1. Open the SendouQ match page match.id
    await new SendouQMatchPage(page).goto(match.id);
Mobile nav
Assert
  1. the route's rooms are made active but not opened: the match page keeps the whole viewport until the user asks for the chat

    tab “chat” is visible
    await expect(mobileNav.tab("chat")).toBeVisible();
Chat sidebar
Assert
  1. open chats count is 0
    await expect(chat.locators.openChats).toHaveCount(0);
Act
  1. Open second user browser, workerBaseURL, alpha[0].id → other
    const other = await openSecondUser(browser, workerBaseURL, alpha[0].id);
  2. Try
    try {
    	await new SendouQMatchPage(other.page).goto(match.id);
    	await new ChatSidebar(other.page).chat().send("on my way");
    
    	await expect(mobileNav.tabBadge("chat")).toHaveText("1");
    
    	await mobileNav.openPanel("chat");
    
    	// straight into the room rather than onto the room list
    	await expect(chat.locators.splitPanels).toHaveCount(2);
    	await expect(chat.chat().message("on my way")).toBeVisible();
    	await expect(mobileNav.tabBadge("chat")).toHaveCount(0);
    
    	await chat.chat().send("almost there");
    
    	await expect(
    		new ChatSidebar(other.page).chat().message("almost there"),
    	).toBeVisible();
    
    	await chat.close();
    
    	await expect(chat.locators.openChats).toHaveCount(0);
    } finally {
    	await other.close();
    }
    1. Open the SendouQ match page match.id
      await new SendouQMatchPage(other.page).goto(match.id);
    2. Send “on my way”
      await new ChatSidebar(other.page).chat().send("on my way");
    3. tab badge “chat” reads “1”
      await expect(mobileNav.tabBadge("chat")).toHaveText("1");
    4. Open panel “chat”
      await mobileNav.openPanel("chat");
    5. straight into the room rather than onto the room list

      split panels count is 2
      await expect(chat.locators.splitPanels).toHaveCount(2);
    6. message “on my way” is visible
      await expect(chat.chat().message("on my way")).toBeVisible();
    7. tab badge “chat” count is 0
      await expect(mobileNav.tabBadge("chat")).toHaveCount(0);
    8. Send “almost there”
      await chat.chat().send("almost there");
    9. message “almost there” is visible
      await expect(
      	new ChatSidebar(other.page).chat().message("almost there"),
      ).toBeVisible();
    10. Close
      await chat.close();
    11. open chats count is 0
      await expect(chat.locators.openChats).toHaveCount(0);
Source
test("Mobile: the chat waits behind the tab bar, then opens straight into the room", async ({
	page,
	browser,
	workerBaseURL,
	factories,
}) => {
	const { match, alpha, bravo } = await createMatch(factories);

	await page.setViewportSize(MOBILE_VIEWPORT);
	await impersonate(page, bravo[0].id);
	await new SendouQMatchPage(page).goto(match.id);

	const mobileNav = new MobileNav(page);
	const chat = new ChatSidebar(page);

	// the route's rooms are made active but not opened: the match page keeps
	// the whole viewport until the user asks for the chat
	await expect(mobileNav.tab("chat")).toBeVisible();
	await expect(chat.locators.openChats).toHaveCount(0);

	const other = await openSecondUser(browser, workerBaseURL, alpha[0].id);
	try {
		await new SendouQMatchPage(other.page).goto(match.id);
		await new ChatSidebar(other.page).chat().send("on my way");

		await expect(mobileNav.tabBadge("chat")).toHaveText("1");

		await mobileNav.openPanel("chat");

		// straight into the room rather than onto the room list
		await expect(chat.locators.splitPanels).toHaveCount(2);
		await expect(chat.chat().message("on my way")).toBeVisible();
		await expect(mobileNav.tabBadge("chat")).toHaveCount(0);

		await chat.chat().send("almost there");

		await expect(
			new ChatSidebar(other.page).chat().message("almost there"),
		).toBeVisible();

		await chat.close();

		await expect(chat.locators.openChats).toHaveCount(0);
	} finally {
		await other.close();
	}
})

Tablet: the chat is a modal, and leaving the page or the layout closes it

Renders//q/match/:id

Setup
Arrange
  1. Create match → { match, alpha, bravo }
    const { match, alpha, bravo } = await createMatch(factories);
  2. desktopViewport = page.viewportSize()!
    const desktopViewport = page.viewportSize()!;
  3. matchRoom = `Match #${match.id}`
    const matchRoom = `Match #${match.id}`;
  4. Use a TABLET_VIEWPORT viewport
    await page.setViewportSize(TABLET_VIEWPORT);
Bbravo[0]
Front page
Act
  1. Open the front page
    await new FrontPage(page).goto();
Chat sidebar
Assert
  1. toggle button is visible
    await expect(chat.locators.toggleButton).toBeVisible();
  2. modal count is 0
    await expect(chat.locators.modal).toHaveCount(0);
Act
  1. Open second user browser, workerBaseURL, alpha[0].id → other
    const other = await openSecondUser(browser, workerBaseURL, alpha[0].id);
Arrange
  1. Try
    try {
    	await new SendouQMatchPage(other.page).goto(match.id);
    	await new ChatSidebar(other.page).chat().send("tablet ping");
    
    	await expect(chat.locators.toggleUnreadBadge).toHaveText("1");
    
    	await chat.open();
    
    	await expect(chat.locators.modal).toBeVisible();
    	await chat.openRoom(matchRoom);
    
    	await expect(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 open
    	await chat.followRoomLink();
    
    	await expect(page).toHaveURL(sendouQMatchPage(match.id));
    	await expect(chat.locators.modal).toHaveCount(0);
    
    	// the route made its rooms active without opening them, so the modal
    	// comes back on the split view
    	await chat.open();
    
    	await expect(chat.locators.splitPanels).toHaveCount(2);
    
    	await chat.chat().send("tablet pong");
    
    	await expect(
    		new ChatSidebar(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 rooms
    	await page.setViewportSize(desktopViewport);
    
    	await expect(chat.locators.modal).toHaveCount(0);
    	await expect(chat.locators.splitPanels).toHaveCount(2);
    } finally {
    	await other.close();
    }
    1. Open the SendouQ match page match.id
      await new SendouQMatchPage(other.page).goto(match.id);
    2. Send “tablet ping”
      await new ChatSidebar(other.page).chat().send("tablet ping");
    3. toggle unread badge reads “1”
      await expect(chat.locators.toggleUnreadBadge).toHaveText("1");
    4. Open
      await chat.open();
    5. modal is visible
      await expect(chat.locators.modal).toBeVisible();
    6. Open room matchRoom
      await chat.openRoom(matchRoom);
    7. message “tablet ping” is visible
      await expect(chat.chat().message("tablet ping")).toBeVisible();
    8. 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

      Follow room link
      await chat.followRoomLink();
    9. the page URL is sendouQMatchPage(match.id)
      await expect(page).toHaveURL(sendouQMatchPage(match.id));
    10. modal count is 0
      await expect(chat.locators.modal).toHaveCount(0);
    11. the route made its rooms active without opening them, so the modal comes back on the split view

      Open
      await chat.open();
    12. split panels count is 2
      await expect(chat.locators.splitPanels).toHaveCount(2);
    13. Send “tablet pong”
      await chat.chat().send("tablet pong");
    14. message “tablet pong” is visible
      await expect(
      	new ChatSidebar(other.page).chat().message("tablet pong"),
      ).toBeVisible();
    15. leaving the tablet range closes the modal too, but the chat stays open, so the desktop rail takes over with the same rooms

      Use a desktopViewport viewport
      await page.setViewportSize(desktopViewport);
    16. modal count is 0
      await expect(chat.locators.modal).toHaveCount(0);
    17. split panels count is 2
      await expect(chat.locators.splitPanels).toHaveCount(2);
Source
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 } = await createMatch(factories);
	const desktopViewport = page.viewportSize()!;
	const matchRoom = `Match #${match.id}`;

	await page.setViewportSize(TABLET_VIEWPORT);
	await impersonate(page, bravo[0].id);
	await new FrontPage(page).goto();

	const chat = new ChatSidebar(page);

	await expect(chat.locators.toggleButton).toBeVisible();
	await expect(chat.locators.modal).toHaveCount(0);

	const other = await openSecondUser(browser, workerBaseURL, alpha[0].id);
	try {
		await new SendouQMatchPage(other.page).goto(match.id);
		await new ChatSidebar(other.page).chat().send("tablet ping");

		await expect(chat.locators.toggleUnreadBadge).toHaveText("1");

		await chat.open();

		await expect(chat.locators.modal).toBeVisible();
		await chat.openRoom(matchRoom);

		await expect(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 open
		await chat.followRoomLink();

		await expect(page).toHaveURL(sendouQMatchPage(match.id));
		await expect(chat.locators.modal).toHaveCount(0);

		// the route made its rooms active without opening them, so the modal
		// comes back on the split view
		await chat.open();

		await expect(chat.locators.splitPanels).toHaveCount(2);

		await chat.chat().send("tablet pong");

		await expect(
			new ChatSidebar(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 rooms
		await page.setViewportSize(desktopViewport);

		await expect(chat.locators.modal).toHaveCount(0);
		await expect(chat.locators.splitPanels).toHaveCount(2);
	} finally {
		await other.close();
	}
})