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:
parent
41527f6ba3
commit
0b7ded9c99
10 changed files with 601 additions and 102 deletions
76
harness/estate/drive.js
Normal file
76
harness/estate/drive.js
Normal 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
108
harness/estate/fakehs.js
Normal 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
15
harness/estate/run.sh
Normal 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
|
||||
Loading…
Reference in a new issue