estate harness: Soul's fake homeserver with member-state echo, and the twodm, reload and convert direct-message scenarios

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 20:14:19 +01:00
commit e59cc93a2a
3 changed files with 61 additions and 105 deletions

View file

@ -1,82 +1,41 @@
const { chromium } = require('playwright');
const fs = require('fs');
const TAG = process.env.TAG;
const out = (n) => `/out/${TAG}-${n}`;
const tl = [];
const T0 = Date.now();
const t = () => `+${Date.now() - T0}ms`;
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'];
(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();
pg.on('console', (m) => { if (m.type() === 'error' && !/OIDC|issuer|endpoint|supported|ERR_NAME|404/.test(m.text())) tl.push('console.error ' + m.text().slice(0, 200)); });
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); });
const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); };
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.waitForTimeout(12000);
await open();
const text = () => pg.evaluate(() => document.body.innerText);
const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); tl.push(`--- ${n} ${t()} url=${pg.url()}\n${await text()}`); };
const buttons = async () => tl.push('buttons ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button')].map((e) => { const r = e.getBoundingClientRect(); return [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; }))));
const small = async () => tl.push('SMALL ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button, a, [role="button"]')].filter((e) => e.getClientRects().length).map((e) => [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(e.getBoundingClientRect().width), Math.round(e.getBoundingClientRect().height)]).filter((r) => r[2] < 44))));
const layout = async () => tl.push('layout ' + JSON.stringify(await pg.evaluate(() => ({ vw: innerWidth, scrollW: document.documentElement.scrollWidth, bodyScrollW: document.body.scrollWidth }))));
await snap('1-landing'); await buttons(); await layout(); await small();
if (process.env.NOROOT === '1') {
tl.push('has Create Room: ' + (await pg.getByText('Create Room', { exact: true }).count()) + ' Join with Address: ' + (await pg.getByText('Join with Address', { exact: true }).count()) + ' finder: ' + (await pg.locator('[aria-label="Find a room"]').count()));
await b.close(); return;
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()); };
if (MODE === 'twodm') {
await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
await pg.waitForTimeout(45000);
} else if (MODE === 'convert') {
await tap('Friend DM'); await pg.waitForTimeout(6000);
log('TIER before', await tierOf('Friend DM'));
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);
log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600)));
await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300));
await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>'')));
if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); }
await pg.waitForTimeout(8000);
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
} else {
await tap('Slow DM'); await pg.waitForTimeout(3000);
log('RELOAD', t()); await open();
await pg.waitForTimeout(30000);
}
const reveal = async (sel) => {
const loc = pg.locator(sel);
if (await loc.count()) return loc;
for (let round = 0; round < 2; round++) 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()) { tl.push(`revealed ${sel} via ${h}`); return loc; } }
}
tl.push(`could not reveal ${sel}`); return loc;
};
// expand everything once for the inventory
for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); tl.push('clicked header ' + h); } }
await snap('2-expanded'); await buttons(); await layout(); await small();
// room finder
const finder = pg.locator('[aria-label="Find a room"], [aria-label="Search"]').first();
await finder.click(); await pg.waitForTimeout(800);
tl.push('after finder url=' + pg.url() + ' dialogs=' + (await pg.locator('[role="dialog"]').count()) + ' inputs=' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('input')].map((i) => i.placeholder))));
await snap('3-finder');
await pg.keyboard.press('Escape'); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
// join with frame poll
const joinPoll = async (name, ms) => {
const loc = await reveal(`[aria-label="Join ${name}"]`);
if (!(await loc.count())) { tl.push(`no Join ${name}`); return; }
const u0 = pg.url(); await loc.first().click(); const c = Date.now(); tl.push(`CLICK Join ${name} ${t()}`);
let card = 0, frames = 0, urlAt = null, firstCardAt = null;
while (Date.now() - c < ms) {
const s = await text(); frames++;
if (s.includes('Summary card')) { card++; if (firstCardAt === null) firstCardAt = Date.now() - c; }
if (urlAt === null && pg.url() !== u0) urlAt = Date.now() - c;
await pg.waitForTimeout(40);
}
tl.push(`POLL ${name}: frames=${frames} cardFrames=${card} firstCardAt=${firstCardAt} urlChangedAt=${urlAt} url=${pg.url()}`);
await snap(`4-join-${name.replace(/\W/g, '')}`);
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500);
};
await joinPoll('Commons', 6000);
await joinPoll('Aetheria', 6000);
await joinPoll('Friend DM', 6000);
// timeout path, room
const slow = async (name) => {
const loc = await reveal(`[aria-label="Join ${name}"]`);
if (!(await loc.count())) { tl.push(`no Join ${name}`); return; }
await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`);
await pg.waitForTimeout(22800); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`);
tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`);
await pg.waitForTimeout(5000); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`);
tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`);
};
await slow('Odin');
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500);
await slow('Slow DM');
// wide: tier placement (wide tiers start open)
await pg.setViewportSize({ width: 1280, height: 800 });
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(6000); await snap('6-wide');
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();
})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); process.exitCode = 1; }).finally(() => fs.writeFileSync(out('timeline.txt'), tl.join('\n')));
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });

View file

@ -9,11 +9,11 @@ const log = [];
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p }));
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test';
const SLOW = new Set([PROJ[5].id, DM2]);
const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [STRAY]: 'Adhoc' };
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test';
const SLOW = new Set([PROJ[5].id, DM2, DM3]);
const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' };
PROJ.forEach((r) => (NAMES[r.id] = r.name));
const pending = []; const pendingAD = []; let ev = 0;
const held = []; const pendingState = []; const pending = []; const pendingAD = []; 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, direct) => [
{ type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
@ -35,6 +35,7 @@ const invites = () => {
PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } };
o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } };
o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } };
return o;
};
const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
@ -61,21 +62,23 @@ http.createServer((req, res) => {
return fs.createReadStream(f).pipe(res);
}
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body);
log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`);
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
if (p.endsWith('/sync')) {
const since = u.searchParams.get('since');
if (!since) {
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc');
return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
}
const respond = () => {
const now = Date.now(); const join = {};
for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) {
const id = pending[i].id; pending.splice(i, 1);
const extra = id === DM1 || id === DM2 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
join[id] = joinedRoom(id, NAMES[id] || id, extra); log.push(`${now} SYNC delivers join ${id}`);
const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id);
}
for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); }
const ad = pendingAD.splice(0);
if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } });
return json(res, 200, { next_batch: since, rooms: {} });
@ -86,12 +89,15 @@ http.createServer((req, res) => {
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.' });
pending.push({ id, at: Date.now() + (SLOW.has(id) ? 24000 : 1000) });
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) });
return json(res, 200, { room_id: id });
}
const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/);
if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); }
if (p.includes('/account_data/') && req.method === 'PUT') {
const type = decodeURIComponent(p.split('/account_data/')[1]);
try { pendingAD.push({ type, content: JSON.parse(body) }); } catch (e) {}
try { const c = JSON.parse(body); pendingAD.push({ type, content: c }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {}
return json(res, 200, {});
}
if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });

View file

@ -1,29 +1,20 @@
# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable:
# bash /src/harness/estate/run.sh
# Scenario A: root resolves. Scenario B (NOROOT=1): the root alias 404s.
#!/usr/bin/env bash
# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src:
# builds the client, then drives three direct-message scenarios against fakehs.cjs.
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
npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; }
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 >/out/pw.log 2>&1
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 /tmp/pw/
runone () { # tag noroot
HSLOG=/out/hslog-$1.txt NOROOT=$2 node /src/harness/estate/fakehs.cjs /src/dist > /out/hs-$1.out 2>&1 &
HS=$!
sleep 2
TAG=$1 NOROOT=$2 node /tmp/pw/drive.cjs; echo "DRIVE $1 RC=$?"
kill $HS
}
runone root 0
runone noroot 1
for t in root noroot; do
echo "=== $t"
grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true
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=$?"
kill $HS; sleep 1
echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
done
ls -la /out | head -30
cat /out/noroot-timeline.txt | grep -m3 -i "could not" || true
echo "=== m.direct and joins (root)"
grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true
tail -1 /out/build.log
echo DONE