// Public share links, driven through a real browser: the owner creates one in // the Share dialog, and a visitor with NO account opens it. // node test/share-link.js // Its own file rather than a block inside browser.js: it has to clear the // session cookie to become a stranger, and that is a rude thing to do in the // middle of a suite that stays signed in throughout. import { spawn } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import assert from 'node:assert' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright-core' const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const PORT = 3311 const BASE = `http://localhost:${PORT}` const DATA = path.join(root, '.share-link-data') const SHOT = process.env.SHOT_DIR || '/tmp/claude-1000/ui-shots' fs.rmSync(DATA, { recursive: true, force: true }) fs.mkdirSync(SHOT, { recursive: true }) const server = spawn('node', ['server/index.js'], { cwd: root, // HOST is what the server puts in the prompt's curl examples; the link the // dialog shows is built from the origin the browser is actually on env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, OAUTH_CLIENT_ID: '', OAUTH_CLIENT_SECRET: '', HOST: BASE }, stdio: ['ignore', 'pipe', 'pipe'], }) server.stderr.on('data', d => process.stderr.write('[server] ' + d)) const wait = ms => new Promise(r => setTimeout(r, ms)) async function waitFor(fn, what, ms = 15000) { const t0 = Date.now() while (Date.now() - t0 < ms) { try { if (await fn()) return } catch {} await wait(150) } throw new Error('timeout: ' + what) } async function main() { await waitFor(async () => (await fetch(`${BASE}/healthz`)).ok, 'server') const browser = await chromium.launch({ headless: true, executablePath: process.env.PW_EXECUTABLE || undefined, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--single-process'], }) const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }) const page = await context.newPage() const consoleErrors = [] page.on('pageerror', e => consoleErrors.push('PAGEERROR: ' + e.message)) page.on('console', m => { if (m.type() === 'error' && !m.text().includes('fonts.g')) consoleErrors.push(m.text()) }) // --- the owner writes something and publishes a link --- await page.goto(`${BASE}/auth/dev?u=alice&next=/`) const doc = await (await page.request.post(`${BASE}/api/docs`, { data: { title: 'Link Shared' } })).json() await page.goto(`${BASE}/d/${doc.id}`, { waitUntil: 'networkidle' }) await page.waitForSelector('.tiptap') await page.click('.tiptap h1') await page.keyboard.press('End') await page.keyboard.press('Enter') await page.keyboard.type('Readable by anyone holding the link.') await wait(1200) await page.click('#share-btn') await page.waitForSelector('#share-pop:not(.hidden)') await page.waitForSelector('#link-share:not(.hidden)') await page.click('#link-toggle') await page.waitForSelector('#link-row:not(.hidden)') await waitFor(async () => /\/p\//.test(await page.inputValue('#link-url')), 'the link appears in the dialog') const link = await page.inputValue('#link-url') assert.ok(link.startsWith(BASE), 'the link uses the origin the browser is on: ' + link) assert.ok(/\/p\/[A-Za-z0-9_-]{32,}$/.test(link), 'long unguessable path: ' + link) await page.screenshot({ path: path.join(SHOT, 'share-dialog.png') }) console.log('✓ owner creates a link from the Share dialog') // --- a stranger: no session cookie, no account --- await context.clearCookies() await page.goto(link, { waitUntil: 'networkidle' }) await page.waitForSelector('.tiptap') await waitFor(async () => (await page.textContent('.tiptap')).includes('Readable by anyone holding the link'), 'a visitor reads the document') const visitor = await page.evaluate(() => ({ editable: window.__editor?.isEditable, signin: !document.getElementById('signin')?.classList.contains('hidden'), share: getComputedStyle(document.getElementById('share-btn')).display, agents: getComputedStyle(document.getElementById('agents-btn')).display, presence: getComputedStyle(document.getElementById('presence')).display, whoami: document.getElementById('whoami')?.textContent || '', })) assert.equal(visitor.editable, false, 'the document is not editable for a visitor') assert.equal(visitor.signin, false, 'no sign-in wall for a link visitor') assert.equal(visitor.share, 'none', 'no Share button') assert.equal(visitor.agents, 'none', 'no Agents button') assert.equal(visitor.presence, 'none', 'the presence row is not rendered for a stranger (hidden, not withheld — see app.css)') assert.match(visitor.whoami, /Read-only/, 'the header says what this is: ' + visitor.whoami) await page.screenshot({ path: path.join(SHOT, 'share-visitor.png') }) console.log('✓ visitor reads it: no account, no sign-in wall, no write UI') // typing must change nothing — the socket refuses writes from a link await page.click('.tiptap p').catch(() => {}) await page.keyboard.type('VANDALISM') await wait(1500) await page.goto(`${BASE}/auth/dev?u=alice&next=/d/${doc.id}`, { waitUntil: 'networkidle' }) const after = await (await page.request.get(`${BASE}/api/docs/${doc.id}`)).json() assert.ok(!after.markdown.includes('VANDALISM'), 'a visitor cannot write: ' + after.markdown.slice(0, 90)) console.log('✓ a visitor typing into it changes nothing') // checked here: the revoke step below visits a dead link on purpose, and a // 404 in the console is the correct outcome of that, not a defect assert.deepEqual(consoleErrors, [], 'no console errors: ' + JSON.stringify(consoleErrors)) console.log('✓ zero console errors') // --- turning it off closes the door on the link already handed out --- await page.waitForSelector('.tiptap') await page.click('#share-btn') await page.waitForSelector('#link-share:not(.hidden)') await page.click('#link-toggle') await waitFor(async () => (await page.textContent('#link-hint')).startsWith('Off'), 'dialog shows the link is off') await context.clearCookies() const revoked = await page.goto(link, { waitUntil: 'domcontentloaded' }) assert.equal(revoked.status(), 404, 'a revoked link is a 404') const stillDead = await page.evaluate(() => !document.getElementById('signin')?.classList.contains('hidden')) assert.ok(stillDead, 'and it shows the sign-in shell, not the document') console.log('✓ revoking kills a link that was already handed out') await browser.close() server.kill() fs.rmSync(DATA, { recursive: true, force: true }) console.log('\nSHARE LINK TEST PASSED') process.exit(0) } main().catch(async err => { console.error('\nSHARE LINK TEST FAILED:', err.message) server.kill() process.exit(1) })