waybetter-for-x/apps/desktop/tests/observer.test.ts
2026-10-01 06:01:13 +07:00

274 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseRelationshipObservations } from '../src/observer/relationships.ts';
import { installResponseObserver, isSupportedPage, MAX_RESPONSE_BYTES, MAX_OBSERVATION_READERS, MAX_OBSERVATION_READ_MS, type PageObservation } from '../src/observer/network.ts';
import { createCaptureQueue, type CaptureAck, type CapturePayload } from '../src/observer/capture-queue.ts';
import { isExcludedComposerPath } from '../src/observer/composer.ts';
const fixture = (relationship: Record<string, unknown> = { following: true, followed_by: false }) => ({
data: { user: { result: { __typename: 'User', rest_id: '123', legacy: { screen_name: 'fixture', ...relationship },
private_text: 'must never escape', email: 'private@example.test' } } },
});
const tick = () => new Promise(resolve => setTimeout(resolve, 15));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
function fakePage(fetch: typeof globalThis.fetch) {
class MockXHR extends EventTarget {
status = 200;
responseType = '';
responseText = JSON.stringify(fixture());
responseURL = 'https://x.com/i/api/graphql/test/UserByScreenName';
response: unknown = null;
sent = 0;
open(..._args: unknown[]) {}
send(..._args: unknown[]) { this.sent++; }
getResponseHeader(name: string) { return name.toLowerCase() === 'content-type' ? 'application/json' : String(this.responseText.length); }
}
const page = Object.assign(new EventTarget(), {
fetch, XMLHttpRequest: MockXHR, CustomEvent,
location: { origin: 'https://x.com', href: 'https://x.com/home' },
top: null as unknown,
});
page.top = page;
return page as unknown as Window & typeof globalThis;
}
const apiUrl = 'https://x.com/i/api/graphql/test/UserByScreenName';
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } });
test('relationship extraction emits only IDs and explicitly observed booleans', () => {
assert.deepEqual(parseRelationshipObservations(fixture()), [{ userId: '123', following: true, followedBy: false }]);
assert.deepEqual(parseRelationshipObservations(fixture({ following: false })), [{ userId: '123', following: false, followedBy: null }]);
assert.deepEqual(parseRelationshipObservations(fixture({ following: 'false' })), []);
assert.deepEqual(parseRelationshipObservations({ __typename: 'Tweet', rest_id: '123', legacy: { following: true } }), []);
});
test('perspectives take precedence; traversal tolerates cycles and deeply nested objects', () => {
const user = { __typename: 'User', rest_id: '123', legacy: { following: true }, relationship_perspectives: { following: false, followed_by: true } };
assert.deepEqual(parseRelationshipObservations(user), [{ userId: '123', following: false, followedBy: true }]);
const cycle: Record<string, unknown> = {}; cycle.self = cycle;
assert.deepEqual(parseRelationshipObservations(cycle), []);
let nested: unknown = user;
for (let i = 0; i < 30; i++) nested = { nested };
assert.deepEqual(parseRelationshipObservations(nested), []);
});
test('only exact https x.com top frames can install; repeat installation is idempotent', () => {
const page = fakePage(async () => jsonResponse(fixture()));
const original = page.fetch;
const handle = installResponseObserver(page);
assert.equal(handle.installed, true);
assert.equal(installResponseObserver(page), handle);
handle.dispose();
assert.equal(page.fetch, original);
Object.assign(page.location, { origin: 'https://x.com.attacker.test' });
assert.equal(isSupportedPage(page), false);
assert.equal(installResponseObserver(page).installed, false);
Object.assign(page.location, { origin: 'https://x.com' });
Object.assign(page, { top: {} });
assert.equal(isSupportedPage(page), false);
});
test('fetch preserves Promise/Response identity, original body, call count, start epoch and redaction', async () => {
const request = deferred<Response>();
let calls = 0;
const page = fakePage(() => { calls++; return request.promise; });
const events: PageObservation[] = [];
const handle = installResponseObserver(page, { onObservation: event => events.push(event) });
handle.advanceEpoch();
const promise = page.fetch(apiUrl);
assert.equal(promise, request.promise);
const response = jsonResponse(fixture());
request.resolve(response);
assert.equal(await promise, response);
assert.deepEqual(await response.json(), fixture());
await tick();
assert.equal(calls, 1);
assert.equal(events.length, 1);
assert.equal(events[0].requestEpoch, 1);
assert.equal(events[0].accountId, null);
assert.equal(JSON.stringify(events).includes('private'), false);
assert.equal(JSON.stringify(events).includes('fixture'), false);
handle.dispose();
});
test('fetch rejection and subscriber exceptions do not change X results', async () => {
const failure = new Error('offline');
const page = fakePage(() => Promise.reject(failure));
const handle = installResponseObserver(page);
await assert.rejects(page.fetch(apiUrl), error => error === failure);
handle.dispose();
const healthy = fakePage(async () => jsonResponse(fixture()));
const listener = installResponseObserver(healthy, { onObservation() { throw new Error('consumer'); } });
assert.deepEqual(await (await healthy.fetch(apiUrl)).json(), fixture());
await tick();
listener.dispose();
});
test('non-API, non-JSON, oversized and malformed payloads are ignored', async () => {
const outputs = [
jsonResponse(fixture()),
new Response(JSON.stringify(fixture()), { headers: { 'content-type': 'text/html' } }),
new Response(JSON.stringify(fixture()), { headers: { 'content-type': 'application/json', 'content-length': String(MAX_RESPONSE_BYTES + 1) } }),
jsonResponse({ padding: 'x'.repeat(MAX_RESPONSE_BYTES), ...fixture() }),
new Response('{invalid', { headers: { 'content-type': 'application/json' } }),
];
const page = fakePage(async () => outputs.shift()!);
const events: PageObservation[] = [];
const handle = installResponseObserver(page, { onObservation: event => events.push(event) });
await page.fetch('https://example.test/i/api/graphql/test');
for (let i = 0; i < 4; i++) await (await page.fetch(apiUrl)).text();
await tick();
assert.deepEqual(events, []);
handle.dispose();
});
test('XHR only observes existing sends and stamps the epoch at send time', () => {
const page = fakePage(async () => jsonResponse({}));
const originalOpen = page.XMLHttpRequest.prototype.open;
const originalSend = page.XMLHttpRequest.prototype.send;
const events: PageObservation[] = [];
const handle = installResponseObserver(page, { onObservation: event => events.push(event) });
const request = new page.XMLHttpRequest();
request.open('GET', apiUrl);
handle.advanceEpoch();
request.send();
request.dispatchEvent(new Event('load'));
request.dispatchEvent(new Event('loadend'));
assert.equal((request as unknown as { sent: number }).sent, 1);
assert.equal(events.length, 1);
assert.equal(events[0].requestEpoch, 1);
handle.dispose();
assert.equal(page.XMLHttpRequest.prototype.open, originalOpen);
assert.equal(page.XMLHttpRequest.prototype.send, originalSend);
});
test('disposing removes in-flight XHR listeners and suppresses late fetch observations', async () => {
const request = deferred<Response>();
const page = fakePage(() => request.promise);
const events: PageObservation[] = [];
const handle = installResponseObserver(page, { onObservation: event => events.push(event) });
const promise = page.fetch(apiUrl);
const xhr = new page.XMLHttpRequest(); xhr.open('GET', apiUrl); xhr.send();
handle.dispose();
request.resolve(jsonResponse(fixture()));
await promise;
xhr.dispatchEvent(new Event('load'));
await tick();
assert.deepEqual(events, []);
});
test('responses from a previous account epoch are discarded', async () => {
const request = deferred<Response>();
const page = fakePage(() => request.promise);
const events: PageObservation[] = [];
const handle = installResponseObserver(page, { onObservation: event => events.push(event) });
const result = page.fetch(apiUrl);
const xhr = new page.XMLHttpRequest(); xhr.open('GET', apiUrl); xhr.send();
handle.advanceEpoch();
request.resolve(jsonResponse(fixture()));
await (await result).text();
xhr.dispatchEvent(new Event('load'));
await tick();
assert.deepEqual(events, []);
handle.dispose();
});
test('a stalled observation times out without cancelling the original response consumer', async () => {
let stream!: ReadableStreamDefaultController<Uint8Array>;
let cancelled = false;
const body = new ReadableStream<Uint8Array>({ start(controller) { stream = controller; }, cancel() { cancelled = true; } });
const response = new Response(body, { headers: { 'content-type': 'application/json' } });
const page = fakePage(async () => response);
const events: PageObservation[] = [];
const handle = installResponseObserver(page, { onObservation: event => events.push(event) });
const original = await page.fetch(apiUrl);
const consumed = original.json();
await new Promise(resolve => setTimeout(resolve, MAX_OBSERVATION_READ_MS + 80));
assert.equal(cancelled, false);
stream.enqueue(new TextEncoder().encode(JSON.stringify(fixture())));
stream.close();
assert.deepEqual(await consumed, fixture());
await tick();
assert.deepEqual(events, []);
handle.dispose();
});
test('in-flight observation readers are capped and disposal preserves each original stream', async () => {
const streams: ReadableStreamDefaultController<Uint8Array>[] = [];
let clones = 0;
const page = fakePage(async () => {
const response = new Response(new ReadableStream<Uint8Array>({ start(controller) { streams.push(controller); } }), { headers: { 'content-type': 'application/json' } });
const clone = response.clone.bind(response);
response.clone = () => { clones++; return clone(); };
return response;
});
const handle = installResponseObserver(page);
const responses = await Promise.all(Array.from({ length: MAX_OBSERVATION_READERS + 3 }, () => page.fetch(apiUrl)));
await tick();
assert.equal(clones, MAX_OBSERVATION_READERS);
handle.dispose();
for (const stream of streams) { stream.enqueue(new TextEncoder().encode('{"ok":true}')); stream.close(); }
assert.deepEqual(await Promise.all(responses.map(response => response.json())), responses.map(() => ({ ok: true })));
});
test('capture queue serializes writes, retains the latest text, and waits for native ACK', async () => {
const calls: CapturePayload[] = [];
const first = deferred<CaptureAck>();
const queue = createCaptureQueue({ composerId: 'fixture-composer', save: async capture => {
calls.push(capture);
return capture.sequence === 1 ? first.promise : { sequence: capture.sequence, revision: capture.sequence };
} });
const snapshot = { text: 'first', kind: 'post' as const, contextUrl: 'https://x.com/home' };
queue.enqueue(snapshot);
await Promise.resolve();
assert.equal(queue.getStatus(), 'saving');
queue.enqueue({ ...snapshot, text: 'middle' });
queue.enqueue({ ...snapshot, text: 'latest' });
assert.equal(calls.length, 1);
assert.notEqual(queue.getStatus(), 'saved');
first.resolve({ sequence: 1, revision: 1 });
await queue.flush();
assert.deepEqual(calls.map(c => c.text), ['first', 'latest']);
assert.deepEqual(calls.map(c => c.sequence), [1, 2]);
assert.equal(queue.getStatus(), 'saved');
queue.enqueue({ ...snapshot, text: '' });
await queue.flush();
assert.equal(calls[2].text, '');
assert.equal(calls[2].sequence, 3);
});
test('ambiguous failed saves retry the exact payload/sequence before new text', async () => {
const calls: CapturePayload[] = [];
let attempts = 0;
const queue = createCaptureQueue({ composerId: 'fixture-composer', save: async capture => {
calls.push({ ...capture });
if (attempts++ === 0) throw new Error('unknown IPC outcome');
return { sequence: capture.sequence, revision: capture.sequence };
} });
const snapshot = { text: 'persist me', kind: 'reply' as const, contextUrl: 'https://x.com/example/status/123' };
queue.enqueue(snapshot); await queue.flush();
assert.equal(queue.getStatus(), 'error');
queue.enqueue({ ...snapshot, text: 'newer edit' }); await queue.flush();
assert.deepEqual(calls[0], calls[1]);
assert.equal(calls[2].text, 'newer edit');
assert.equal(calls[2].sequence, 2);
assert.equal(queue.getStatus(), 'saved');
});
test('incorrect native ACK never produces a saved state', async () => {
const statuses: string[] = [];
const queue = createCaptureQueue({ composerId: 'fixture-composer', onStatus: status => statuses.push(status), save: async () => ({ sequence: 999, revision: 1 }) });
queue.enqueue({ text: 'text', kind: 'quote', contextUrl: 'https://x.com/home' });
await queue.flush();
assert.equal(queue.getStatus(), 'error');
assert.equal(statuses.includes('saved'), false);
});
test('DM/chat and Grok routes are excluded without matching unrelated profile names', () => {
for (const path of ['/messages', '/messages/123', '/i/chat', '/i/chat/123', '/i/grok']) assert.equal(isExcludedComposerPath(path), true);
for (const path of ['/home', '/messagesapp', '/someone/status/123']) assert.equal(isExcludedComposerPath(path), false);
});