Estate: stewards from the hierarchy's Space child content, joins wait for the store, room finder on search, Create room and Join with address, Elsewhere group, invited DMs

Steward groups read org.gamecult.steward from the m.space.child content the hierarchy
returns, so an invited-only member gets them; root-state stewardships and
GameCultStewardships are deleted. Tapping Join waits for the client store to hold the
room before navigating (no invite-card frame). The header search opens the room finder
on every width. Create room and Join with address are navigator actions; joined non-DM
rooms outside the Space sit under Elsewhere; invited DMs list in Direct with Join.
harness/estate holds the fake-homeserver Playwright pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 17:46:11 +01:00
commit 0b7ded9c99
10 changed files with 601 additions and 102 deletions

76
harness/estate/drive.js Normal file
View file

@ -0,0 +1,76 @@
// Drives the built client against fakehs.js on a phone viewport and asserts the estate navigator.
const { chromium } = require('playwright');
const fs = require('fs');
const out = (n) => `/out/${n}`;
const tl = [];
const fails = [];
const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails.push(what); };
(async () => {
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
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.getByText('Create Room').first().waitFor({ timeout: 30000 });
await pg.waitForTimeout(2000);
const text = () => pg.evaluate(() => document.body.innerText);
const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); };
const open = async (name) => { const h = pg.getByText(name, { exact: true }); if (await h.count()) { await h.first().click(); await pg.waitForTimeout(300); } };
// Entry points
const t0 = await text();
check(/Create Room/.test(t0) && /Join with Address/.test(t0), 'Create Room and Join with Address are navigator actions');
await pg.getByText('Join with Address').first().click();
await pg.waitForTimeout(400);
check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt');
await pg.keyboard.press('Escape');
await pg.waitForTimeout(300);
if (await pg.locator('input[name="addressInput"]').count()) await pg.getByText('Cancel').first().click();
// Header search is the room finder, on a phone
await pg.locator('[aria-label="Find a room"]').first().click();
await pg.waitForTimeout(500);
check((await pg.locator('input[placeholder="Search"]').count()) === 1 && !/#\/home\/search/.test(pg.url()), 'header search opens the room finder, not message search');
await snap('finder');
await pg.keyboard.press('Escape');
await pg.waitForTimeout(300);
// Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM
for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n);
await snap('expanded');
const t1 = (await text()).toLowerCase();
check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state');
check(t1.includes('elsewhere') && t1.includes('meeting'), 'a joined room outside the Space is listed under Elsewhere');
check(t1.includes('direct messages') && (await pg.locator('[aria-label="Join Void"]').count()) === 1, 'an invited DM is listed with Join');
// Join: no invite card at any frame, then the room
await pg.locator('[aria-label="Join Aetheria"]').first().click();
const t = Date.now();
let card = 0, room = null;
while (Date.now() - t < 6000) {
const body = await text();
if (/Summary card/.test(body)) card++;
if (room === null && /welcome to Aetheria/.test(body)) room = Date.now() - t;
await pg.waitForTimeout(40);
}
await snap('joined');
check(card === 0, `no invite card at any frame after the tap (saw ${card})`);
check(room !== null, `the room opened (after ${room} ms)`);
// Join the DM: lands in the room too
await pg.goto('http://127.0.0.1:8080/#/home/');
await pg.waitForTimeout(3000);
await open('Direct Messages');
if (await pg.locator('[aria-label="Join Void"]').count()) {
await pg.locator('[aria-label="Join Void"]').first().click();
let dmCard = 0; const t2 = Date.now();
while (Date.now() - t2 < 5000) { if (/Summary card/.test(await text())) dmCard++; await pg.waitForTimeout(40); }
check(dmCard === 0 && /\/direct\//.test(pg.url()), 'the invited DM opens in Direct with no invite card');
}
await b.close();
})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); fails.push('error'); }).finally(() => {
fs.writeFileSync(out('timeline.txt'), tl.join('\n'));
console.log(tl.join('\n'));
process.exitCode = fails.length ? 1 : 0;
});

108
harness/estate/fakehs.js Normal file
View file

@ -0,0 +1,108 @@
// Fake Matrix homeserver + static server for the built client (harness/estate/run.sh).
// Scenario: an invitee to the GameCult Space and 6 project rooms (never a member of the
// Space, so the client cannot read its state), joined to #general and to #meeting, a room
// outside the Space, and invited to a DM. The steward of each project room is carried only
// in the m.space.child content of the hierarchy, as the accounts script writes it.
const http = require('http'), fs = require('fs'), path = require('path');
const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
const log = [];
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
const DM = '!dm:test', MEETING = '!meeting:test';
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) }));
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test';
const pendingJoin = [];
let ev = 0;
const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() });
const strip = (id, name, space, dm) => [
{ type: 'm.room.create', state_key: '', sender: '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
...(name ? [{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }] : []),
...(dm ? [{ type: 'm.room.member', state_key: '@void:test', sender: '@void:test', content: { membership: 'join', displayname: 'Void' } }] : []),
{ type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
{ type: 'm.room.member', state_key: ME, sender: dm ? '@void:test' : '@admin:test', content: dm ? { membership: 'invite', is_direct: true } : { membership: 'invite' } },
];
const joinedRoom = (id, name, extra = [], space = false) => ({
state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] },
timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false },
ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {},
});
const invites = () => {
const o = {};
o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } };
o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } };
o[OP] = { invite_state: { events: strip(OP, 'Operator') } };
PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
o[DM] = { invite_state: { events: strip(DM, null, false, true) } };
return o;
};
const child = (id, alias) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(STEWARD[alias] ? { 'org.gamecult.steward': { repo: 'GameCult/' + alias, steward: '@' + STEWARD[alias] + ':test', mind: STEWARD[alias], source: 'x' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra });
const hierarchy = () => [
hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PSP)] }),
hroom(GEN, 'General', '#general:test'),
hroom(OP, 'Operator', '#operator:test'),
hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, r.alias.slice(1).split(':')[0])) }),
...PROJ.map((r) => hroom(r.id, r.name, r.alias)),
];
let syncN = 1;
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); };
const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n'));
http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname);
if (req.method === 'OPTIONS') return json(res, 200, {});
if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) {
let f = path.join(DIST, p === '/' ? 'index.html' : p);
if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html');
res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' });
return fs.createReadStream(f).pipe(res);
}
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
log.push(`${Date.now()} ${req.method} ${p}`);
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
if (p.endsWith('/sync')) {
if (!u.searchParams.get('since')) log.push(`${Date.now()} INITIAL SYNC`);
const since = u.searchParams.get('since');
if (!since) {
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[MEETING] = joinedRoom(MEETING, 'Meeting');
const inv = invites();
return json(res, 200, { next_batch: 's1', rooms: { join, invite: inv, leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
}
const wait = () => {
if (pendingJoin.length) {
const id = pendingJoin.shift(); const r = PROJ.find((x) => x.id === id) || { name: id === DM ? 'Void' : id };
const join = {}; join[id] = joinedRoom(id, r.name);
log.push(`${Date.now()} SYNC delivers join ${id}`);
return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } });
}
return json(res, 200, { next_batch: since, rooms: {} });
};
return setTimeout(wait, pendingJoin.length ? 2500 : 1500);
}
const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/);
if (req.method === 'POST' && jm) {
const id = jm[1];
log.push(`${Date.now()} JOIN ${id}`);
if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
pendingJoin.push(id);
return json(res, 200, { room_id: id });
}
if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });
if (p.includes('/directory/room/')) return json(res, 200, { room_id: ROOT, servers: ['test'] });
if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } });
if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} });
if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } });
if (p.includes('/filter')) return json(res, 200, { filter_id: '1' });
if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {});
if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' });
if (p.includes('/members')) return json(res, 200, { chunk: [] });
if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' });
if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} });
if (p.includes('/devices')) return json(res, 200, { devices: [] });
if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' });
if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] });
return json(res, 200, {});
});
}).listen(PORT, () => console.log('fakehs up'));
setInterval(flush, 500);

15
harness/estate/run.sh Normal file
View file

@ -0,0 +1,15 @@
# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable:
# bash /src/harness/estate/run.sh
set -e
cd /src
mkdir -p /out
npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1
npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log
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 >/out/pw.log 2>&1
cp /src/harness/estate/drive.js /tmp/pw/
HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.js /src/dist > /out/hs.out 2>&1 &
HS=$!
sleep 2
node /tmp/pw/drive.js; echo DRIVE_RC=$?
kill $HS