From 7453a0bd323da8861ef015221320224eb5fb79a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:20:28 +0100 Subject: [PATCH 1/9] estate: every join of a direct invite goes through the pre-join filing; only a Matrix 4xx with an errcode is a refusal The room card's Join (deep links, permalinks, history) and /join joined through mx.joinRoom and never filed the DM. They now go through activateEstateRoom. A gateway 5xx, or a status with no errcode, has an unknown outcome like a dropped connection: the entry stays and the tap is retryable. The harness gains Soul's s6 deeplink and gw504 drives. Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-routes.cjs | 33 +++++++++++++++ harness/estate/fakehs.cjs | 1 + harness/estate/run.sh | 6 ++- src/app/components/room-card/RoomCard.tsx | 10 +++-- .../features/estate/joinEstateRoom.test.ts | 42 ++++++++++++++++--- src/app/features/estate/joinEstateRoom.ts | 38 +++++++++++++---- src/app/hooks/useCommands.ts | 3 +- 7 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 harness/estate/drive-routes.cjs diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs new file mode 100644 index 00000000..05e89df1 --- /dev/null +++ b/harness/estate/drive-routes.cjs @@ -0,0 +1,33 @@ +const { chromium } = require('playwright'); +const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); }; + if (MODE === 'deeplink') { + for (const route of ['direct', 'home']) { + await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); + const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); + const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); + if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + } + await tiers('after deeplink join'); + } else if (MODE === 'gw504') { + const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); + for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); + const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); + await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)'); + } + await quit(0); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index a8180204..4a6875e0 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -91,6 +91,7 @@ http.createServer((req, res) => { if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); const prior = pending.find((q) => q.id === DM2 || q.id === DM3); pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); } if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } return json(res, 200, { room_id: id }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 26b026b8..70fa5d13 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,7 +8,7 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/ +cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & @@ -20,7 +20,7 @@ done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" + MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -30,3 +30,5 @@ failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 echo DONE +DRIVER=drive-routes failure deeplink +DRIVER=drive-routes failure gw504 JOIN_504=1 diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 34a7e24b..a5e9b3a9 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { MatrixError } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; +import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -197,8 +198,11 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers); + if (result.kind === 'refused') throw new Error(result.message); + }, [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index d206a608..6fccae10 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -52,13 +52,13 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 })) + vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 })); + const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -78,7 +78,7 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record }) => { +const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record }) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -105,7 +105,8 @@ const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 }); + if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, @@ -149,6 +150,37 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom when a gateway answers instead of the server', () => { + it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { + const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); + + it('keeps the room listed on a 5xx even when it carries an errcode', async () => { + const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + +describe('joinEstateRoomIdOrAlias, the room card and /join', () => { + it('files a direct invite opened by its link before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']); + expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' }); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('joins an alias or an unheld room without touching m.direct', async () => { + const { mx, calls } = recordingClient(); + await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); + expect(calls).toEqual(['joinRoom']); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index a3be428b..3cd3bf6d 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -8,7 +8,7 @@ export type EstateActivation = | { kind: 'refused'; message: string }; const LOST_JOIN_RESPONSE = - 'The connection dropped before the server answered. The join may have gone through; tap again to retry.'; + 'The server did not answer the join. It may have gone through; tap again to retry.'; type JoinClient = Parameters[0]; @@ -21,21 +21,28 @@ type JoinClient = Parameters[0]; * An invite marked is_direct carries the mark in the invite event the client * already holds, and the join event does not repeat it. So the room is listed * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused by a server answer. A join that fails without one (lost - * response, dropped connection) may still have been accepted, so the entry - * stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. The inbox Accept goes through here too: one owner for joining. + * the join is refused by a Matrix error answer (a 4xx carrying an errcode). A + * join that fails any other way (lost response, dropped connection, a gateway's + * 5xx or a status with no errcode) may still have been accepted upstream, so the + * entry stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. Every join of a room the client holds an invite for goes through + * here: the inbox Accept, the room card, /join. One owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, - room: Pick + room: Pick & { via?: string[] } ): Promise => { if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; const refusal = (e: unknown): EstateActivation => { const err = e as { data?: { error?: string }; message?: string }; return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; }; - const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number'; + const refusedByServer = (e: unknown) => { + const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; + const status = err.httpStatus; + const errcode = err.errcode ?? err.data?.errcode; + return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'; + }; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() @@ -49,12 +56,27 @@ export const activateEstateRoom = async ( const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { - if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; + if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); return refusal(e); } }; +/** + * activateEstateRoom for a caller that holds only an id or alias (room card, + * /join): a room the client has an invite for is an invited room. + */ +export const joinEstateRoomIdOrAlias = ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => + activateEstateRoom(mx, { + roomId: roomIdOrAlias, + state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable', + via, + }); + type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..15a21d1e 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -26,6 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; +import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -233,7 +234,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) ); roomIdOrAliases.forEach(async (idOrAlias) => { - await mx.joinRoom(idOrAlias); + await joinEstateRoomIdOrAlias(mx, idOrAlias); }); }, }, From 873c0e30a2b84d3a686cf085db0b30cdaa7fc409 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:20:42 +0100 Subject: [PATCH 2/9] estate harness: run the s6 route drives last --- harness/estate/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 70fa5d13..379d28ca 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -29,6 +29,6 @@ failure jdrop JOIN_DROP=1 failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 -echo DONE DRIVER=drive-routes failure deeplink DRIVER=drive-routes failure gw504 JOIN_504=1 +echo DONE From 00ec686b1b49a8cf2063de507fbd793a4f598c0c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:33:06 +0100 Subject: [PATCH 3/9] estate tests: the server-refusal and invited-room fixtures carry an errcode and a membership Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 6fccae10..07ed43ea 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -68,6 +68,7 @@ describe('activateEstateRoom', () => { it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, + errcode: 'M_FORBIDDEN', data: { error: 'You are not invited to this room.' }, }); const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); @@ -85,6 +86,7 @@ const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; d const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ + getMyMembership: () => 'invite', getMember: () => ({ events: { member: { From b20b95876252d0d60c0990308ac4e931b20e1521 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:34:49 +0100 Subject: [PATCH 4/9] estate: prettier Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 33 +++++++++++++++---- src/app/features/estate/joinEstateRoom.ts | 4 ++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 07ed43ea..8e009ef0 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -52,13 +52,19 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })) + vi + .fn() + .mockRejectedValue( + Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })); + const bare = recordingClient( + vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -79,7 +85,13 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record }) => { +const dmClient = (opts: { + status?: number; + errcode?: string; + refuse?: boolean; + drop?: boolean; + invites: Record; +}) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -107,8 +119,13 @@ const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; d joinRoom: async (roomId: string) => { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); - if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode }); + if (opts.refuse) + throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) + throw Object.assign(new Error('Gateway Time-out'), { + httpStatus: opts.status, + errcode: opts.errcode, + }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, @@ -162,7 +179,11 @@ describe('activateEstateRoom when a gateway answers instead of the server', () = }); it('keeps the room listed on a 5xx even when it carries an errcode', async () => { - const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + const c = dmClient({ + status: 500, + errcode: 'M_UNKNOWN', + invites: { '!dm1:test': '@friend:test' }, + }); await activateEstateRoom(c.mx, tap('!dm1:test')); expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); }); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 3cd3bf6d..d69df82c 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -41,7 +41,9 @@ export const activateEstateRoom = async ( const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; const status = err.httpStatus; const errcode = err.errcode ?? err.data?.errcode; - return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'; + return ( + typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string' + ); }; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) From 8f1bb54b4b1d48bb1c0b7a95663b6219aa162c71 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:35:35 +0100 Subject: [PATCH 5/9] estate: every client join goes through joinEstateRoomIdOrAlias; /join reports refusals RoomTombstone, RoomIntro and the lobby RoomItem/SpaceItem joined with mx.joinRoom beside the pre-join filing; they now call joinEstateRoomOrThrow, which delegates to the one path. /join hands each refusal to the user instead of discarding it. Tests pin the via pass-through and the 400/429/499 refusal bounds. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/room-card/RoomCard.tsx | 5 +- src/app/components/room-intro/RoomIntro.tsx | 3 +- .../features/estate/joinEstateRoom.test.ts | 68 ++++++++++++++++++- src/app/features/estate/joinEstateRoom.ts | 43 +++++++++++- src/app/features/lobby/RoomItem.tsx | 12 ++-- src/app/features/lobby/SpaceItem.tsx | 7 +- src/app/features/room/RoomTombstone.tsx | 7 +- src/app/hooks/useCommands.ts | 13 +--- 8 files changed, 124 insertions(+), 34 deletions(-) diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index a5e9b3a9..64aaaacb 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -31,7 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; -import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -200,8 +200,7 @@ export const RoomCard = as<'div', RoomCardProps>( const [joinState, join] = useAsyncCallback( useCallback(async () => { - const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers); - if (result.kind === 'refused') throw new Error(result.message); + await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); }, [mx, roomIdOrAlias, viaServers]) ); const joining = diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index ce550992..2e4e3ebc 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; @@ -43,7 +44,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) + useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 8e009ef0..cc1d232f 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom'; +import { + activateEstateRoom, + awaitMyJoin, + joinEstateRoomIdOrAlias, + joinEstateRoomOrThrow, + runJoinCommand, +} from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -95,6 +101,7 @@ const dmClient = (opts: { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; + const joinOpts: unknown[] = []; const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ @@ -116,7 +123,8 @@ const dmClient = (opts: { writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string) => { + joinRoom: async (roomId: string, opts?: unknown) => { + joinOpts.push(opts); // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); if (opts.refuse) @@ -130,7 +138,7 @@ const dmClient = (opts: { return { roomId }; }, }; - return { mx: mx as never, writes, joined, current: () => direct }; + return { mx: mx as never, writes, joined, joinOpts, current: () => direct }; }; const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); @@ -169,6 +177,22 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom refusal bounds', () => { + it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_LIMIT_EXCEEDED', invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).not.toMatch(/tap again to retry/); + expect(c.current()).toEqual({}); + }); + + it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + describe('activateEstateRoom when a gateway answers instead of the server', () => { it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); @@ -197,6 +221,12 @@ describe('joinEstateRoomIdOrAlias, the room card and /join', () => { expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); }); + it('hands the via servers to the join', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']); + expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]); + }); + it('joins an alias or an unheld room without touching m.direct', async () => { const { mx, calls } = recordingClient(); await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); @@ -204,6 +234,38 @@ describe('joinEstateRoomIdOrAlias, the room card and /join', () => { }); }); +describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => { + it('files a direct invite and answers the joined room id', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test'); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('throws the refusal and unfiles the room', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); + expect(c.current()).toEqual({}); + }); +}); + +describe('runJoinCommand, /join', () => { + it('reports each refusal and joins the others', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m)); + expect(reported).toEqual(['M_FORBIDDEN']); + expect(c.joined).toHaveLength(1); + }); + + it('reports nothing when the join is accepted', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m)); + expect(reported).toEqual([]); + expect(c.joined).toHaveLength(1); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index d69df82c..4e1629d0 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -1,5 +1,11 @@ import { EstateRoom } from './buildEstate'; -import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix'; +import { + addRoomIdToMDirect, + isRoomAlias, + isRoomId, + removeRoomIdFromMDirect, +} from '../../utils/matrix'; +import { splitWithSpace } from '../../utils/common'; import { isDirectInvite } from '../../utils/room'; export type EstateActivation = @@ -25,8 +31,9 @@ type JoinClient = Parameters[0]; * join that fails any other way (lost response, dropped connection, a gateway's * 5xx or a status with no errcode) may still have been accepted upstream, so the * entry stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. Every join of a room the client holds an invite for goes through - * here: the inbox Accept, the room card, /join. One owner for joining. + * arrives. Every client join goes through here: the estate tap, the inbox Accept, + * the room card, /join, the lobby and space Join buttons, the tombstone and + * predecessor links. One owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, @@ -79,6 +86,36 @@ export const joinEstateRoomIdOrAlias = ( via, }); +/** + * joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a + * useAsyncCallback button): the joined room id, or an Error carrying the refusal. + */ +export const joinEstateRoomOrThrow = async ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via); + if (result.kind === 'refused') throw new Error(result.message); + return result.roomId; +}; + +/** + * The /join command: every room id or alias in the payload is joined, and each + * refusal is handed to report, never dropped. + */ +export const runJoinCommand = async ( + mx: JoinClient, + payload: string, + report: (message: string) => void +): Promise => { + const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a)); + const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a))); + results.forEach((r) => { + if (r.kind === 'refused') report(r.message); + }); +}; + type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 7de59acd..132aaf8c 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,7 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; +import { JoinRule, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; @@ -38,6 +38,7 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomJoinButtonProps = { roomId: string; @@ -46,8 +47,8 @@ type RoomJoinButtonProps = { function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -60,9 +61,8 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - {joinState.error.data?.error || joinState.error.message} + {joinState.error.message} - {joinState.error.name} } @@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { size="400" filled tabIndex={0} - aria-label={joinState.error.data?.error || joinState.error.message} + aria-label={joinState.error.message} /> )} diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index ed3eaff9..e1ffbfe5 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -38,6 +38,7 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; function SpaceProfileLoading() { return ( @@ -112,8 +113,8 @@ function UnjoinedSpaceProfile({ }: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx index 24f0b80e..3556e6fc 100644 --- a/src/app/features/room/RoomTombstone.tsx +++ b/src/app/features/room/RoomTombstone.tsx @@ -8,6 +8,7 @@ import { Membership } from '../../../types/matrix/room'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { getViaServers } from '../../plugins/via-servers'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { @@ -18,16 +19,14 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); + return joinEstateRoomOrThrow(mx, replacementRoomId, via); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateRoom(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data); }; return ( diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 15a21d1e..11f379dc 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -15,7 +15,6 @@ import { addRoomIdToMDirect, getDMRoomFor, guessDmRoomUserId, - isRoomAlias, isRoomId, isServerName, isUserId, @@ -26,7 +25,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; -import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom'; +import { runJoinCommand } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -228,15 +227,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { [Command.Join]: { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', - exe: async (payload) => { - const rawIds = splitWithSpace(payload); - const roomIdOrAliases = rawIds.filter( - (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) - ); - roomIdOrAliases.forEach(async (idOrAlias) => { - await joinEstateRoomIdOrAlias(mx, idOrAlias); - }); - }, + exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)), }, [Command.Leave]: { name: Command.Leave, From 6950da865e12e7f3703cf7915ef3f2e153184f83 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:37:35 +0100 Subject: [PATCH 6/9] estate harness: every drive asserts the outcome it shows and run.sh fails when one does not Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/check.cjs | 18 +++++++++++++++++ harness/estate/drive-failures.cjs | 33 +++++++++++++++++++++---------- harness/estate/drive-routes.cjs | 20 ++++++++++++++----- harness/estate/drive.cjs | 14 ++++++++++--- harness/estate/run.sh | 9 +++++---- 5 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 harness/estate/check.cjs diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs new file mode 100644 index 00000000..172fa301 --- /dev/null +++ b/harness/estate/check.cjs @@ -0,0 +1,18 @@ +// Assertions for the estate drives: every drive states the outcome it exists to show and exits +// non-zero when the page or the fake homeserver's log says otherwise. +const fs = require('fs'); +const fails = []; +exports.expect = (label, ok, detail = '') => { + console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); + if (!ok) fails.push(label); +}; +/** Exit code for the drive: 4 when any assertion failed. */ +exports.done = () => (fails.length ? 4 : 0); +/** The fake homeserver's request log (HSLOG is shared with the server process). */ +exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); +/** Bodies of the account-data PUTs for m.direct, in order. */ +exports.mDirectPuts = () => + exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); +/** Join requests the server saw for a room id. */ +exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 9c81424a..1172c853 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,6 +1,7 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -21,7 +22,8 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); @@ -38,23 +40,34 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); }; if (MODE === 'pjfail') { await tap('Friend DM'); await pg.waitForTimeout(3000); - const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); - await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); + const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200)); + expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test'))); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry'); + expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); + expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); } else if (MODE === 'jdrop') { await tap('Friend DM'); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); - await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); + const bb = await body(); const k = bb.search(/tap again to retry/i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); + expect('a dropped join shows the retryable message', k >= 0, bb.slice(0, 200)); + await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); + expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); + expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); } else if (MODE === 'admin') { - await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate'); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate'); + expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend); } else if (MODE === 'inboxadmin') { - await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox'); + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox'); + expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend); } else if (MODE === 'inboxreload') { await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); - await pg.waitForTimeout(30000); await tiers('inbox reload'); + await pg.waitForTimeout(30000); const r = await tiers('inbox reload'); + expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); + const l = pg.locator('[aria-label="Join Slow DM"]'); const n = await l.count(); log('SLOW_DM_STILL_JOINABLE', n); + expect('a join cut off by a reload stays joinable', n === 1, String(n)); } - await quit(0); + await quit(done()); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs index 05e89df1..5def6635 100644 --- a/harness/estate/drive-routes.cjs +++ b/harness/estate/drive-routes.cjs @@ -1,4 +1,5 @@ const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf, hsLog } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; @@ -13,21 +14,30 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const f = await tierOf('Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + let joined = false; if (MODE === 'deeplink') { for (const route of ['direct', 'home']) { await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); - if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } } - await tiers('after deeplink join'); + const f = await tiers('after deeplink join'); + const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0])); + const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0]; + expect('the deep link offers one Join button', joined, 'no Join button'); + expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`); + expect('the DM is filed under Direct Messages', isDm(f), f); } else if (MODE === 'gw504') { const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); - await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)'); + expect('a gateway timeout shows the retryable message', /tap again to retry/i.test(bb), bb.slice(0, 200)); + await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); + expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); + expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); } - await quit(0); + await quit(done()); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index ed9950ce..cf794323 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,8 +1,10 @@ const { chromium } = require('playwright'); +const { expect, done, isDm } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let tierBefore; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); @@ -20,7 +22,7 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El await pg.waitForTimeout(45000); } else if (MODE === 'convert') { await tap('Friend DM'); await pg.waitForTimeout(6000); - log('TIER before', await tierOf('Friend DM')); + tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore); await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); @@ -36,6 +38,12 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El await pg.waitForTimeout(30000); } for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); - await b.close(); + const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); + log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); + if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } + else if (MODE === 'convert') { + expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore); + expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend); + } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow); + await b.close(); process.exitCode = done(); })().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 379d28ca..65242ab0 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,19 +8,19 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ +cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?" + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -31,4 +31,5 @@ failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 DRIVER=drive-routes failure deeplink DRIVER=drive-routes failure gw504 JOIN_504=1 -echo DONE +echo "FAILED_DRIVES:${FAILED:- none}" +[ -z "$FAILED" ] From 9b66b2b25075a469fe36239a81c560b89d468505 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:52:45 +0100 Subject: [PATCH 7/9] estate harness: read a row's tier from a fresh visit without toggling tiers closed; catch the retryable message before sync replaces the row Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/check.cjs | 28 +++++++++++++++++++ harness/estate/drive-failures.cjs | 13 ++++----- harness/estate/drive-routes.cjs | 11 ++++---- harness/estate/drive.cjs | 5 ++-- .../features/estate/joinEstateRoom.test.ts | 10 +++++-- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs index 172fa301..656d532e 100644 --- a/harness/estate/check.cjs +++ b/harness/estate/check.cjs @@ -15,4 +15,32 @@ exports.mDirectPuts = () => exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); /** Join requests the server saw for a room id. */ exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */ +const tierOf = (pg, name) => + pg.evaluate((n) => { + const s = document.body.innerText.split('\n').map((x) => x.trim()); + const i = s.indexOf(n); + if (i < 0) return 'absent'; + for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; + return 'none-above'; + }, name); +/** + * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is + * opened only if the row is not already showing, so reading never closes what is open. + */ +exports.revealedTier = async (pg, name) => { + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(2500); + let t = await tierOf(pg, name); + for (const h of HEADERS) { + if (t !== 'absent') break; + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); } + } + return t; +}; +/** True when the page text matches re within ms of now (a message can be replaced by a row change). */ +exports.sawText = (pg, re, ms) => + pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false); exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 1172c853..d53d88a4 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,7 +1,7 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf } = require('./check.cjs'); +const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -20,9 +20,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); await open(); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tierOf = (name) => revealedTier(pg, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab @@ -47,9 +47,8 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); } else if (MODE === 'jdrop') { - await tap('Friend DM'); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/tap again to retry/i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); - expect('a dropped join shows the retryable message', k >= 0, bb.slice(0, 200)); + await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw); + expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200)); await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); @@ -66,7 +65,7 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const l = pg.locator('[aria-label="Join Slow DM"]'); const n = await l.count(); log('SLOW_DM_STILL_JOINABLE', n); + const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n); expect('a join cut off by a reload stays joinable', n === 1, String(n)); } await quit(done()); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs index 5def6635..75c9a8df 100644 --- a/harness/estate/drive-routes.cjs +++ b/harness/estate/drive-routes.cjs @@ -1,5 +1,5 @@ const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf, hsLog } = require('./check.cjs'); +const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; @@ -13,8 +13,7 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); - const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const f = await tierOf('Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; let joined = false; if (MODE === 'deeplink') { for (const route of ['direct', 'home']) { @@ -32,9 +31,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); } else if (MODE === 'gw504') { const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); - expect('a gateway timeout shows the retryable message', /tap again to retry/i.test(bb), bb.slice(0, 200)); + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); + const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw); + expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200)); await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index cf794323..03bd2ad0 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,5 +1,5 @@ const { chromium } = require('playwright'); -const { expect, done, isDm } = require('./check.cjs'); +const { expect, done, isDm, revealedTier } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -15,7 +15,7 @@ let tierBefore; await open(); const text = () => pg.evaluate(() => document.body.innerText); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tierOf = (name) => revealedTier(pg, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; if (MODE === 'twodm') { await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); @@ -37,7 +37,6 @@ let tierBefore; log('RELOAD', t()); await open(); await pg.waitForTimeout(30000); } - for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index cc1d232f..f3f868da 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -123,8 +123,8 @@ const dmClient = (opts: { writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string, opts?: unknown) => { - joinOpts.push(opts); + joinRoom: async (roomId: string, joinArgs?: unknown) => { + joinOpts.push(joinArgs); // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); if (opts.refuse) @@ -179,7 +179,11 @@ describe('activateEstateRoom when the join gets no answer', () => { describe('activateEstateRoom refusal bounds', () => { it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { - const c = dmClient({ status, errcode: 'M_LIMIT_EXCEEDED', invites: { '!dm1:test': '@friend:test' } }); + const c = dmClient({ + status, + errcode: 'M_LIMIT_EXCEEDED', + invites: { '!dm1:test': '@friend:test' }, + }); const result = await activateEstateRoom(c.mx, tap('!dm1:test')); expect(result.kind).toBe('refused'); expect((result as { message: string }).message).not.toMatch(/tap again to retry/); From a4da78edc5dcc14e684a2c85780ab8975f50cd05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:00:38 +0100 Subject: [PATCH 8/9] estate tests: an unfiled room leaves its inviter's list empty, not the map Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index f3f868da..0bc61a9f 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -187,7 +187,7 @@ describe('activateEstateRoom refusal bounds', () => { const result = await activateEstateRoom(c.mx, tap('!dm1:test')); expect(result.kind).toBe('refused'); expect((result as { message: string }).message).not.toMatch(/tap again to retry/); - expect(c.current()).toEqual({}); + expect(c.current()).toEqual({ '@friend:test': [] }); }); it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { @@ -248,7 +248,7 @@ describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', it('throws the refusal and unfiles the room', async () => { const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); - expect(c.current()).toEqual({}); + expect(c.current()).toEqual({ '@friend:test': [] }); }); }); From b33d8de95638b2057dc3f731a00ad8587f5757ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:04:14 +0100 Subject: [PATCH 9/9] estate tests: pin the failed-filing refusal, a non-DM refusal leaving m.direct alone, and awaitMyJoin's event name Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 0bc61a9f..932e7cce 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -96,6 +96,7 @@ const dmClient = (opts: { errcode?: string; refuse?: boolean; drop?: boolean; + failWrite?: boolean; invites: Record; }) => { let direct: Record | undefined; @@ -117,6 +118,7 @@ const dmClient = (opts: { }), getAccountData: () => (direct ? { getContent: () => direct } : undefined), setAccountData: async (_t: string, content: Record) => { + if (opts.failWrite) throw new Error('account data write failed'); await new Promise((r) => { setTimeout(r, 5); }); @@ -177,6 +179,29 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom when the filing cannot be written', () => { + it('reports the failure and sends no join', async () => { + const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'account data write failed' }); + expect(c.joined).toEqual([]); + }); +}); + +describe('activateEstateRoom when a room that is not a direct message refuses', () => { + it('reports the refusal and leaves m.direct alone', async () => { + const joinRoom = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); + const { mx, calls } = recordingClient(joinRoom); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result.kind).toBe('refused'); + expect(calls).toEqual(['joinRoom']); + }); +}); + describe('activateEstateRoom refusal bounds', () => { it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { const c = dmClient({ @@ -278,8 +303,12 @@ describe('awaitMyJoin', () => { return { mx: { getRoom: () => (membership ? { getMyMembership: () => membership! } : null), - on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h), - off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h), + on: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.add(h); + }, + off: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.delete(h); + }, }, handlers, become: (roomId: string, m: string) => {