173 lines
8.3 KiB
JavaScript
173 lines
8.3 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { chromium } from 'playwright';
|
|
|
|
// All navigation is intercepted locally. This test never contacts X or publishes content.
|
|
const observer = await readFile(new URL('../src-tauri/observer.js', import.meta.url), 'utf8');
|
|
const chromiumArgs = process.env.CHROMIUM_ARGS_JSON ? JSON.parse(process.env.CHROMIUM_ARGS_JSON) : ['--no-sandbox'];
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined,
|
|
args: chromiumArgs.filter(arg => !['--disable-web-security', '--disable-site-isolation-trials'].includes(arg)),
|
|
});
|
|
const checks = [];
|
|
function fixture(extra = '', editorTestId = 'tweetTextarea_0') {
|
|
return `<!doctype html><html><body><main><div data-testid="primaryColumn"><div role="dialog"><section data-testid="composer-fixture"><div data-testid="${editorTestId}" role="textbox" contenteditable="true"></div>${extra}<button data-testid="tweetButton">Post</button></section></div></div></main></body></html>`;
|
|
}
|
|
const context = await browser.newContext();
|
|
await context.addInitScript(() => {
|
|
window.__fixtureOriginalFetch = window.fetch;
|
|
window.__fixtureOriginalPushState = history.pushState;
|
|
window.__fixtureOriginalReplaceState = history.replaceState;
|
|
window.__fixtureOriginalXhrOpen = XMLHttpRequest.prototype.open;
|
|
window.__fixtureCaptures = [];
|
|
window.__fixtureAcks = [];
|
|
window.__TAURI__ = { core: { invoke: (command, args) => new Promise((resolve, reject) => {
|
|
window.__fixtureCaptures.push({ command, ...args.capture });
|
|
window.__fixtureAcks.push({ resolve: () => resolve({ sequence: args.capture.sequence, revision: args.capture.sequence }), reject });
|
|
}) } };
|
|
});
|
|
let markup = fixture();
|
|
await context.route('**/*', route => {
|
|
const url = new URL(route.request().url());
|
|
return url.origin === 'https://x.com'
|
|
? route.fulfill({ status: 200, contentType: 'text/html', body: markup })
|
|
: route.abort();
|
|
});
|
|
const page = await context.newPage();
|
|
const editor = () => page.locator('[data-testid="tweetTextarea_0"]');
|
|
const badge = () => page.locator('#x-workspace-capture-status');
|
|
const calls = () => page.evaluate(() => window.__fixtureCaptures);
|
|
async function open(path, html = fixture()) {
|
|
markup = html;
|
|
await page.goto(`https://x.com${path}`);
|
|
await page.addScriptTag({ content: observer });
|
|
// The status toast stays attached but hidden while idle.
|
|
await badge().waitFor({ state: 'attached' });
|
|
}
|
|
async function awaitCount(count) {
|
|
await page.waitForFunction(count => window.__fixtureCaptures.length === count, count);
|
|
}
|
|
async function ack() {
|
|
await page.evaluate(() => window.__fixtureAcks.shift().resolve());
|
|
}
|
|
async function saved() {
|
|
await page.waitForFunction(() => document.querySelector('#x-workspace-capture-status').textContent.includes('Saved locally'));
|
|
}
|
|
try {
|
|
await open('/home');
|
|
await editor().fill('first text');
|
|
await awaitCount(1);
|
|
assert.equal((await calls())[0].command, 'capture_draft');
|
|
assert.equal((await calls())[0].kind, 'post');
|
|
assert.equal((await calls())[0].contextUrl, 'https://x.com/home');
|
|
assert.equal((await badge().innerText()).includes('Saved locally'), false);
|
|
await ack(); await saved();
|
|
await editor().fill('second text');
|
|
assert.equal((await badge().innerText()).includes('Saved locally'), false);
|
|
await awaitCount(2);
|
|
await editor().fill('latest text');
|
|
await page.waitForTimeout(300);
|
|
assert.equal((await calls()).length, 2);
|
|
await ack(); await awaitCount(3);
|
|
assert.equal((await calls())[2].text, 'latest text');
|
|
await ack(); await saved();
|
|
await editor().fill('');
|
|
await awaitCount(4);
|
|
assert.equal((await calls())[3].text, '');
|
|
await ack(); await saved();
|
|
checks.push('post capture, delayed ACK, immediate dirty badge, serialized latest edit, empty revision');
|
|
|
|
await editor().fill('closed modal text');
|
|
await page.locator('[role="dialog"]').evaluate(element => element.remove());
|
|
await awaitCount(5);
|
|
assert.equal((await calls())[4].text, 'closed modal text');
|
|
await ack();
|
|
checks.push('disconnect flushes the pending composer text');
|
|
|
|
await open('/someone/status/123');
|
|
await editor().fill('reply text'); await awaitCount(1);
|
|
assert.equal((await calls())[0].kind, 'reply');
|
|
assert.equal((await calls())[0].contextUrl, 'https://x.com/someone/status/123');
|
|
await ack();
|
|
checks.push('reply keeps original status context');
|
|
|
|
await open('/compose/post', fixture('<div data-testid="quoteTweet"><a href="/someone/status/321?private=discard">Quoted post</a></div>'));
|
|
await editor().fill('quote text'); await awaitCount(1);
|
|
assert.equal((await calls())[0].kind, 'quote');
|
|
assert.equal((await calls())[0].contextUrl, 'https://x.com/someone/status/321');
|
|
await ack();
|
|
checks.push('quote keeps status context without query data');
|
|
|
|
await open('/messages/123');
|
|
await editor().fill('DM text must not be captured');
|
|
await page.waitForTimeout(400);
|
|
assert.deepEqual(await calls(), []);
|
|
assert.match(await badge().innerText(), /Unsupported surface/);
|
|
checks.push('DM route refuses capture even with a deceptive tweet selector');
|
|
|
|
await open('/home', fixture('', 'unknownEditor'));
|
|
await page.locator('[contenteditable]').fill('unknown markup');
|
|
await page.waitForTimeout(400);
|
|
assert.deepEqual(await calls(), []);
|
|
assert.match(await badge().textContent(), /No supported composer/);
|
|
assert.equal(await badge().isVisible(), false, 'idle status must not cover X');
|
|
checks.push('unknown markup is visibly unsupported');
|
|
|
|
await open('/home', fixture('<div data-testid="tweetTextarea_1" role="textbox" contenteditable="true"></div>'));
|
|
await editor().fill('thread must be unsupported');
|
|
await page.waitForTimeout(400);
|
|
assert.deepEqual(await calls(), []);
|
|
checks.push('multi-part thread surface is not partially captured');
|
|
|
|
await open('/home');
|
|
await page.addScriptTag({ content: observer });
|
|
assert.equal(await badge().count(), 1);
|
|
await editor().fill('single capture'); await awaitCount(1);
|
|
await page.waitForTimeout(400);
|
|
assert.equal((await calls()).length, 1);
|
|
await ack();
|
|
checks.push('re-injection remains idempotent');
|
|
|
|
await open('/first/status/100');
|
|
await editor().fill('first target pending text');
|
|
await page.evaluate(() => {
|
|
const editor = document.querySelector('[data-testid="tweetTextarea_0"]');
|
|
editor.textContent = '';
|
|
history.pushState({ fixture: true }, '', '/second/status/200');
|
|
});
|
|
await editor().fill('second target text');
|
|
await awaitCount(2);
|
|
const reused = await calls();
|
|
assert.equal(reused[0].text, 'first target pending text');
|
|
assert.equal(reused[0].contextUrl, 'https://x.com/first/status/100');
|
|
assert.equal(reused[1].text, 'second target text');
|
|
assert.equal(reused[1].contextUrl, 'https://x.com/second/status/200');
|
|
assert.notEqual(reused[0].composerId, reused[1].composerId);
|
|
await ack(); await ack(); await saved();
|
|
checks.push('reused DOM node across SPA targets gets a fresh composer identity and immutable old context');
|
|
|
|
const routing = await page.evaluate(() => {
|
|
const result = history.replaceState({ preserved: true }, '', location.pathname);
|
|
let errorName;
|
|
try { history.pushState({}, '', 'https://unrelated.test/'); } catch (error) { errorName = error.name; }
|
|
return { returnedUndefined: result === undefined, state: history.state, errorName, origin: location.origin };
|
|
});
|
|
assert.deepEqual(routing, { returnedUndefined: true, state: { preserved: true }, errorName: 'SecurityError', origin: 'https://x.com' });
|
|
const restored = await page.evaluate(() => {
|
|
window[Symbol.for('x-workspace.observer.installation.v1')].dispose();
|
|
return {
|
|
fetch: window.fetch === window.__fixtureOriginalFetch,
|
|
push: history.pushState === window.__fixtureOriginalPushState,
|
|
replace: history.replaceState === window.__fixtureOriginalReplaceState,
|
|
xhr: XMLHttpRequest.prototype.open === window.__fixtureOriginalXhrOpen,
|
|
panelRemoved: !document.getElementById('x-workspace-capture-status'),
|
|
};
|
|
});
|
|
assert.deepEqual(restored, { fetch: true, push: true, replace: true, xhr: true, panelRemoved: true });
|
|
checks.push('native history return, state and security errors are unchanged; disposal restores all hooks');
|
|
|
|
console.log(JSON.stringify({ ok: true, fixtureOnly: true, liveXVerified: false, checks }, null, 2));
|
|
} finally {
|
|
await browser.close();
|
|
}
|