274 lines
13 KiB
TypeScript
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);
|
|
});
|