test/markdown-viewer-link-open.test.js
Rama main · réplica de lectura del repositorio original.
// Real chat Markdown link -> exact pane/shell file viewer. Uses synthetic
// Markdown only, in a temp project served by the isolated pane fixture
// (isolated HOME/CLAY_HOME, provider and Git-write fences set before imports).
var test = require('node:test'), assert = require('node:assert/strict'), cp = require('node:child_process');
var fs = require('node:fs'), path = require('node:path');
var chromium = require('playwright').chromium;
var hasSavedViews = fs.existsSync(path.join(__dirname, '../lib/public/modules/split-view-presentations.js'));
test('markdown view mode defaults to rendered, honors line/diff targets and project-scoped choices', async function () {
global.navigator = { userAgent: 'node', platform: '', maxTouchPoints: 0 };
var store = await import('file://' + path.join(__dirname, '../lib/public/modules/store.js'));
store.createStore({ fileViewerMarkdownModes: {} });
var mode = await import('file://' + path.join(__dirname, '../lib/public/modules/markdown-view-mode.js'));
['a.md', 'constructor', '__proto__.md', 'toString.md', 'hasOwnProperty'].forEach(function (name) {
assert.equal(mode.initialMarkdownRendered('p1', name, null, null), true, name);
});
assert.equal(mode.initialMarkdownRendered('p1', 'a.md', { path: 'a.md', line: 3 }, null), false);
assert.equal(mode.initialMarkdownRendered('p1', 'a.md', { path: 'other.md', line: 3 }, null), true);
assert.equal(mode.initialMarkdownRendered('p1', 'a.md', null, { type: 'diff' }), false);
mode.rememberMarkdownViewMode('p1', 'docs/a.md', false);
assert.equal(mode.initialMarkdownRendered('p1', 'docs/a.md', null, null), false);
assert.equal(mode.initialMarkdownRendered('p2', 'docs/a.md', null, null), true, 'same relative path in another project is independent');
mode.rememberMarkdownViewMode('p1', '__proto__.md', false);
assert.equal(mode.initialMarkdownRendered('p1', '__proto__.md', null, null), false);
assert.equal(mode.initialMarkdownRendered('p1', 'constructor', null, null), true);
mode.resetMarkdownViewModes();
assert.equal(mode.initialMarkdownRendered('p1', 'docs/a.md', null, null), true);
delete global.navigator;
});
async function startFixture(t, env) {
var child = cp.fork(path.join(__dirname, 'fixtures/pane-reuse-server.cjs'), [], { env: Object.assign({}, process.env, { CLAY_PANE_BENCH: '' }, env), stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
t.after(function () { child.kill('SIGTERM'); });
var info = await new Promise(function (resolve, reject) { child.once('message', resolve); child.once('error', reject); });
var cwd = fs.realpathSync(path.join(info.root, 'reuse'));
fs.mkdirSync(path.join(cwd, 'docs'));
fs.writeFileSync(path.join(cwd, 'docs/SYNTH.ko.md'), '# Synthetic heading\n\nBody **bold** text.\n\n- item\n');
fs.writeFileSync(path.join(cwd, 'plain.js'), 'var synthetic = 1;\n');
var browser = await chromium.launch(); t.after(function () { return browser.close(); });
var page = await browser.newPage(), errors = [];
page.on('pageerror', function (error) { errors.push(error.message); });
var origin = 'http://127.0.0.1:' + info.port;
await require('./fixtures/source-module-runtime.cjs').install(page);
await page.goto(origin + '/fixture/login');
var data = await (await page.request.get(origin + '/fixture/state')).json();
function viewer() {
return page.evaluate(function () {
var el = document.querySelector('.file-instance') || document.getElementById('file-viewer');
return { hidden: el.classList.contains('hidden'), rendered: !!el.querySelector('.file-viewer-markdown h1'),
source: !!el.querySelector('code.language-markdown'), toggle: el.classList.contains('file-instance') ? !el.querySelector('[data-action=preview]').disabled : !document.getElementById('file-viewer-render').classList.contains('hidden') };
});
}
return { page: page, origin: origin, cwd: cwd, data: data, errors: errors, viewer: viewer,
absolute: path.join(cwd, 'docs/SYNTH.ko.md'),
say: function (id, text) { return page.request.post(origin + '/fixture/live?kind=turn&id=' + id + '&text=' + encodeURIComponent(text)); },
closeViewer: async function () { var instance = page.locator('.file-instance'); if (await instance.count()) await instance.getByRole('button', { name: 'Close file pane', exact: true }).click(); else await page.click('#file-viewer-close'); await page.waitForFunction(function () { return !document.querySelector('.file-instance') && document.getElementById('file-viewer').classList.contains('hidden'); }); },
clickIn: async function (scope, label, expect) {
await scope.getByRole('button', { name: label, exact: true }).last().click();
await page.waitForFunction(function (kind) {
var el = document.querySelector('.file-instance') || document.getElementById('file-viewer');
if (el.classList.contains('hidden')) return false;
if (kind === 'markdown') return !!el.querySelector('.file-viewer-markdown h1');
if (kind === 'source') return !!el.querySelector('code.language-markdown');
return !!el.querySelector('.file-viewer-body .file-viewer-code-content, .file-viewer-body pre');
}, expect, { timeout: 10000 });
},
panesReady: function (count, ids) {
return page.waitForFunction(function (args) {
var frames = Array.from(document.querySelectorAll('#split-host .split-pane-frame'));
return frames.length === args.count && args.ids.every(function (id) {
var frame = frames.find(function (item) { return Number(item.dataset.sessionId) === id; });
var live = frame && frame.clayPane && frame.clayPane.identity();
return live && live.ready && live.sessionId === id && !frame.dataset.requestedSessionId;
});
}, { count: count, ids: ids }, { timeout: 30000 });
},
pane: function (id) { return page.frameLocator('.split-pane-frame[data-session-id="' + id + '"]'); } };
}
async function exerciseLinks(f, scope, label) {
await f.clickIn(scope, 'Open file ' + f.absolute, 'markdown');
var state = await f.viewer();
assert.deepEqual([state.rendered, state.source, state.toggle], [true, false, true], label + ': absolute link opens rendered first');
if (await f.page.locator('.file-instance').count()) await f.page.locator('.file-instance [data-action=source]').click(); else await f.page.click('#file-viewer-render');
assert.equal((await f.viewer()).source, true, label + ': source toggle');
await f.page.evaluate(function () { var tab = document.querySelector('.file-instance .tool-pane-title') || document.querySelector('.file-viewer-tab'); tab.click(); });
await f.page.waitForTimeout(400);
assert.equal((await f.viewer()).source, true, label + ': explicit source choice survives tab refocus');
if (await f.page.locator('.file-instance').count()) await f.page.locator('.file-instance [data-action=preview]').click(); else await f.page.click('#file-viewer-render');
assert.equal((await f.viewer()).rendered, true);
await f.closeViewer();
await f.clickIn(scope, 'Open file docs/SYNTH.ko.md', 'markdown');
assert.equal((await f.viewer()).rendered, true, label + ': relative link opens rendered first');
await f.closeViewer();
await f.clickIn(scope, 'Open file docs/SYNTH.ko.md:3', 'source');
assert.equal((await f.viewer()).source, true, label + ': explicit line target opens source rows');
await f.closeViewer();
await f.clickIn(scope, 'Open file plain.js', 'code');
state = await f.viewer();
assert.deepEqual([state.rendered, state.source, state.toggle], [false, false, false], label + ': non-Markdown unchanged');
await f.closeViewer();
}
var links = 'Absolute [a](ABS) relative [rel](docs/SYNTH.ko.md) lined [lined](docs/SYNTH.ko.md:3) code [plain.js](plain.js)';
test('real chat links open in a legacy split pair', { timeout: 120000 }, async function (t) {
var f = await startFixture(t, {});
var group = f.data.groups[0];
await f.page.evaluate(async function (g) { (await import('/p/reuse/modules/split-view.js')).openGroup(g); }, group);
await f.panesReady(2, [group.pair.driverId, group.pair.workerId]);
await f.say(group.pair.driverId, links.replace('ABS', f.absolute));
await f.say(group.pair.workerId, 'Worker [w](' + f.absolute + ')');
await exerciseLinks(f, f.pane(group.pair.driverId), 'driver pane');
await f.clickIn(f.pane(group.pair.workerId), 'Open file ' + f.absolute, 'markdown');
assert.equal((await f.viewer()).rendered, true, 'worker pane link');
assert.deepEqual(f.errors, []);
});
test('real chat links open from the ordinary single-session view', { timeout: 120000 }, async function (t) {
var f = await startFixture(t, {});
var id = f.data.standaloneId;
await f.page.request.post(f.origin + '/fixture/switch?id=' + id);
await f.page.waitForFunction(function (id) { return document.getElementById('messages').dataset.controlSessionId === String(id) || !!document.querySelector('.split-pane-frame[data-session-id="' + id + '"]'); }, id, { timeout: 30000 });
await f.say(id, links.replace('ABS', f.absolute));
var paned = await f.page.locator('.split-pane-frame[data-session-id="' + id + '"]').count();
await exerciseLinks(f, paned ? f.pane(id) : f.page.locator('#messages'), paned ? 'single-session pane' : 'native shell');
assert.deepEqual(f.errors, []);
});
test('saved-view pane links open, and stale or forged pane messages are rejected', { timeout: 120000, skip: !hasSavedViews && 'requires the saved-view (PR599) UI' }, async function (t) {
var f = await startFixture(t, { CLAY_PANE_PRESENTATION: '1' });
var view = f.data.restoreView;
assert.ok(view, 'fixture created a real saved view');
await f.say(view.pair.driverId, links.replace('ABS', f.absolute));
await f.page.locator('[data-split-view-id="' + view.id + '"] .split-view-group-open').first().click();
await f.panesReady(view.members.length, view.members);
var pane = f.pane(view.pair.driverId);
// Forged and stale messages from the live pane window must not open anything.
async function forge(patch) {
await f.page.evaluate(function (args) {
var frame = document.querySelector('.split-pane-frame[data-session-id="' + args.id + '"]');
var binding = frame.clayPane.identity().binding;
var message = Object.assign({ type: 'clay-pane-open-file', paneBinding: binding, path: 'docs/SYNTH.ko.md', line: null, column: null,
projectSlug: frame.dataset.projectSlug, sessionId: String(args.id) }, args.patch);
frame.contentWindow.parent.postMessage(message, location.origin);
}, { id: view.pair.driverId, patch: patch });
await f.page.waitForTimeout(500);
assert.equal((await f.viewer()).hidden, true, 'rejected: ' + JSON.stringify(Object.keys(patch)));
}
await forge({ paneBinding: null });
await forge({ paneBinding: { requestId: 'stale', generation: -1 } });
await forge({ sessionId: String(view.pair.driverId + 100) });
await forge({ projectSlug: 'other-project' });
await forge({ path: '' });
await f.page.evaluate(function (id) { document.querySelector('.split-pane-frame[data-session-id="' + id + '"]').clayPane.suspend(); }, view.pair.driverId);
await forge({});
await f.page.reload({ waitUntil: 'domcontentloaded' });
await f.panesReady(view.members.length, view.members);
await exerciseLinks(f, f.pane(view.pair.driverId), 'saved view after reload');
assert.deepEqual(f.errors, []);
});