diff --git a/packages/api-model/src/builtins.ts b/packages/api-model/src/builtins.ts index c54a2e5a..c5e50c6b 100644 --- a/packages/api-model/src/builtins.ts +++ b/packages/api-model/src/builtins.ts @@ -77,8 +77,6 @@ export const BUILTINS: Record> = { }, scala: { ...qualifiedScala, - def: 'https://docs.scala-lang.org/scala3/book/methods-most.html', - extension: 'https://docs.scala-lang.org/scala3/reference/contextual/extension-methods.html', String: scala('scala/Predef$'), Boolean: scala('scala/Boolean'), Int: scala('scala/Int'), Long: scala('scala/Long'), Unit: scala('scala/Unit'), Option: scala('scala/Option'), Either: scala('scala/util/Either'), diff --git a/packages/api-model/src/link.ts b/packages/api-model/src/link.ts index 701374df..43f4e424 100644 --- a/packages/api-model/src/link.ts +++ b/packages/api-model/src/link.ts @@ -1,6 +1,6 @@ import { tokenizeDoc, type DocSpan } from './doc/roles.ts' import type { InventoryEntry } from './inventory.ts' -import type { ApiSymbol, SymbolKind } from './model.ts' +import type { ApiSymbol, Signature, SymbolKind } from './model.ts' import { builtinHref } from './builtins.ts' /** @@ -65,6 +65,14 @@ const PY_INTERSPHINX: Record = { const PY_DOCS = 'https://docs.python.org/3/' +/** Syntax words in the native declarations rendered as complete signatures. */ +const SIGNATURE_KEYWORDS: Record> = Object.fromEntries(Object.entries({ + kotlin: 'public private protected internal class interface object fun val var suspend override abstract open final data sealed enum inline reified crossinline noinline vararg in out where companion constructor operator infix tailrec external expect actual by', + scala: 'def extension val var class trait object type given using implicit inline transparent opaque override abstract final sealed case lazy private protected open infix export derives end', + fsharp: 'module namespace type member static abstract override interface inherit let rec mutable inline internal private public of with get set new val and when', + ts: 'readonly keyof typeof infer extends unique', +}).map(([port, words]) => [port, new Set(words.split(' '))])) + /** * Builtin exceptions, which docstrings reference constantly and which all live * on one page. @@ -379,6 +387,11 @@ export class SymbolIndex { if (byName) return { href: this.hrefFor(byName), external: false, symbol: byName } } + // Scala and Kotlin default imports take precedence over a JDK inventory's + // bare aliases. An explicit java.util.Vector import still resolves there. + const builtin = this.lang ? builtinHref(this.lang, clean) : undefined + if (builtin && ['kotlin', 'scala'].includes(this.lang ?? '')) return { href: builtin, external: true } + // Intersphinx order: exact name, then the shortest suffix. An inventory // is authoritative for its own project, so it is consulted before the // built-in table rather than after. @@ -389,7 +402,6 @@ export class SymbolIndex { if (hit) return { href: inv.baseUrl + hit.uri, external: true, project: inv.project } } - const builtin = this.lang ? builtinHref(this.lang, clean) : undefined if (builtin) return { href: builtin, external: true } // Everything below is CPython's, and applies to CPython only. `time`, @@ -446,8 +458,8 @@ export class SymbolIndex { * identifier boundaries and passing everything else through verbatim is what * makes those all work without a grammar for type syntax. */ - linkType(annotation: string, context?: ApiSymbol): { text: string; link?: LinkTarget; declaration?: true }[] { - const out: { text: string; link?: LinkTarget; declaration?: true }[] = [] + linkType(annotation: string, context?: ApiSymbol, signature?: Signature): { text: string; link?: LinkTarget; declaration?: true; keyword?: true }[] { + const out: { text: string; link?: LinkTarget; declaration?: true; keyword?: true }[] = [] // Identifiers, including dotted ones; everything else is punctuation, // whitespace or a string literal and passes through untouched. // `::` is part of a name, not punctuation between two. Splitting there @@ -468,6 +480,28 @@ export class SymbolIndex { out.push({ text: ident }) continue } + // A declaration's bindings are not references to other APIs. Keep this + // separate from prose resolution, where an explicit parameter reference + // can still point to that parameter's documentation. + if (SIGNATURE_KEYWORDS[this.lang ?? '']?.has(ident)) { + out.push({ text: ident, keyword: true }) + continue + } + if (signature && context && (ident === context.name || ident.endsWith(`.${context.name}`)) && + /\b(?:def|fun|class|interface|trait|object|type|val|var|member|let)\s+$/.test(annotation.slice(0, m.index))) { + const receiver = ident.slice(0, -context.name.length).replace(/\.$/, '') + if (receiver) out.push(...this.linkType(receiver, context), { text: '.' }) + out.push({ text: context.name, declaration: true }) + continue + } + if (signature && ( + ((signature.params.some((param) => param.name === ident) || signature.receiver?.name === ident) && + /^\s*\??\s*:(?!:)/.test(annotation.slice(m.index! + ident.length))) || + signature.typeParams?.includes(ident) + )) { + out.push({ text: ident, declaration: true }) + continue + } // Object fields, callback parameters and tuple labels declare names; // the types after their colons still use ordinary reference resolution. if (this.lang === 'ts' && /^\s*\??\s*:(?!:)/.test(annotation.slice(m.index! + ident.length)) && diff --git a/packages/api-model/src/nav-config.ts b/packages/api-model/src/nav-config.ts index 2c268779..5f25c139 100644 --- a/packages/api-model/src/nav-config.ts +++ b/packages/api-model/src/nav-config.ts @@ -186,7 +186,7 @@ const CHAIN: { id: string; label: string; match: Match }[] = [ match: { kind: 'anyOf', of: [ - nameRe('Internal|Generated'), + nameRe('Internal|Generated|^__fuzz_'), // A public API can live in a private implementation directory. Lua // exposes Server, Session, Window, Pane and Client from `_internal/`; // their public contract outranks that layout detail while retained diff --git a/packages/api-model/test/builtins.test.ts b/packages/api-model/test/builtins.test.ts index d28fdc3e..bd66c507 100644 --- a/packages/api-model/test/builtins.test.ts +++ b/packages/api-model/test/builtins.test.ts @@ -40,4 +40,15 @@ describe('standard types in product signatures', () => { expect(signature.find((span) => span.text === 'toString')?.link).toBeUndefined() expect(signature.find((span) => span.text === 'String')?.link?.href).toMatch(/^https:\/\//) }) + + it('uses Scala collections before bare aliases from the JDK inventory', () => { + const index = new SymbolIndex([], () => '#', 'scala') + index.addInventory('https://docs.oracle.com/', ['Vector', 'java.util.Vector'].map((name) => ({ + name, type: 'class', priority: 1, uri: 'java/util/Vector.html', dispname: '-', + })), ['scala'], 'Java SE') + expect(index.resolve('Vector')?.href).toBe('https://www.scala-lang.org/api/3.x/scala/collection/immutable/Vector.html') + expect(index.resolve('java.util.Vector')?.href).toBe('https://docs.oracle.com/java/util/Vector.html') + expect(index.resolve('def')).toBeUndefined() + expect(index.resolve('extension')).toBeUndefined() + }) }) diff --git a/packages/api-model/test/jvm.test.ts b/packages/api-model/test/jvm.test.ts index 4e9545f5..8f9c9025 100644 --- a/packages/api-model/test/jvm.test.ts +++ b/packages/api-model/test/jvm.test.ts @@ -36,6 +36,28 @@ extension [F[_]](self: Box[F, String]) { expect(index.resolve('B', 'class', map)?.href).toBe('#example.Box.map') expect(index.resolve('self', 'any', map)?.href).toBe('#example.Box') expect(index.resolve('UnresolvedType', 'class', map)).toBeUndefined() + const signature = map.signatures[0] + const spans = index.linkType(signature.raw!, map, signature) + expect(spans.filter((span) => span.link).map((span) => span.text)).toEqual(['Box', 'String']) + expect(spans.map((span) => span.text).join('')).toBe(signature.raw) + }) + it('links the types in an overload while leaving its bindings plain', async () => { + const root = fixture('Server.scala', `package example +class Server +class Window +class WindowId +extension (self: Server) { + def windows(id: WindowId): Vector[Window] = TODO +} +`) + const { symbols } = await extractJvm('scala', [root]) + const method = symbols.find((s) => s.name === 'windows')! + const index = new SymbolIndex(symbols, (symbol) => `#${symbol.id}`, 'scala') + const signature = method.signatures[0] + const spans = index.linkType(signature.raw!, method, signature) + expect(spans.filter((span) => span.link).map((span) => span.text)).toEqual(['Server', 'WindowId', 'Vector', 'Window']) + expect(spans.map((span) => span.text).join('')).toBe(signature.raw) + expect(index.resolve('id', 'any', method)?.href).toContain('.parameter.id') }) it.each(['kotlin', 'scala'] as const)('keeps %s links outside generic code', async (port) => { const doc = port === 'kotlin' ? '[the server][Handle]' : '[[Handle the server]]' @@ -89,6 +111,11 @@ public val KotlinPane.Companion.id: TextField expect(symbols.some((s) => /\.java$|\.implementation$/.test(s.id))).toBe(false) expect(symbols.find((s) => s.id === 'example.Pane')!.signatures[0]).toMatchObject({ raw: 'public class Pane', params: [] }) const capture = symbols.find((s) => s.id === 'example.Pane.capture')! + const index = new SymbolIndex(symbols, (symbol) => `#${symbol.id}`, 'kotlin') + const signature = capture.signatures[0] + const spans = index.linkType(signature.raw!, capture, signature) + expect(spans.filter((span) => span.link).map((span) => span.text)).toEqual(['Pane', 'Boolean', 'List', 'String']) + expect(spans.map((span) => span.text).join('')).toBe(signature.raw) expect(capture.parent).toBe('example.Pane') expect(capture.modifiers).toEqual(['async', 'overload']) expect(capture.signatures).toHaveLength(2) diff --git a/scripts/check-api-fidelity.mjs b/scripts/check-api-fidelity.mjs index 788d09bb..0dac57e4 100755 --- a/scripts/check-api-fidelity.mjs +++ b/scripts/check-api-fidelity.mjs @@ -34,6 +34,7 @@ if (!root) { const { API_MODEL_PORTS: PORT_DEFS } = await import(`file://${join(repoRoot, 'site/src/lib/ports.ts')}`) const PORTS = PORT_DEFS.map((p) => p.slug) const HOOKS = [ + 'data-symbol-id', 'data-domain', 'data-objtype', 'data-badge-count', @@ -83,6 +84,8 @@ function entriesIn(html) { const failures = [] const summary = [] +const decode = (value) => value?.replace(/&(amp|lt|gt|quot|#39);/g, (_, entity) => + ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[entity]) for (const port of PORTS) { const model = JSON.parse(readFileSync(join(repoRoot, `site/src/data/api/${port}.json`), 'utf8')) @@ -110,9 +113,8 @@ for (const port of PORTS) { for (const attrs of entriesIn(html)) { entries++ - const id = attrs.id?.replace(/&(amp|lt|gt|quot|#39);/g, (_, entity) => - ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[entity]) - const symbol = symbols.get(id) + const id = decode(attrs.id) + const symbol = symbols.get(decode(attrs['data-symbol-id'])) if (!symbol?.inheritedFrom || symbol.source.file) eligible++ if (symbol && sourceUrl(model, symbol, '/reference/') && attrs['data-has-source'] !== 'true') { failures.push(`${port}: ${rel}#${id} omits its known source link`) diff --git a/scripts/normalize-native-shell.mjs b/scripts/normalize-native-shell.mjs index 1b28c729..cf533df4 100644 --- a/scripts/normalize-native-shell.mjs +++ b/scripts/normalize-native-shell.mjs @@ -54,6 +54,20 @@ export async function normalizeNativeShell(directory, prefix, { sphinxPort, vers } else if (/\.(html|css)$/.test(entry.name)) { const before = readFileSync(path, 'utf8') let after = normalize(before) + // Native search delegates to the owned search page for this port/version. + if (port && /^search(?:\.html|\/index\.html)$/.test(relative(directory, path).split(sep).join('/'))) { + const target = `${root}/${port.slug}/${version}/search/` + const href = escapeAttribute(target) + const scriptTarget = JSON.stringify(target).replaceAll('<', '\\u003c') + after = ` +Search + + + + +

Search the ${port.name} documentation.

+` + } if (port && entry.name.endsWith('.html') && !/]*http-equiv=["']refresh["']/i.test(after)) { if (!/<\/head>/i.test(after)) throw new Error(`Native page has no closing head: ${path}`) // Old source refs may predate the shell; replace existing integration once. diff --git a/site/scripts/check-dev.mjs b/site/scripts/check-dev.mjs index 195c64e8..8e223263 100644 --- a/site/scripts/check-dev.mjs +++ b/site/scripts/check-dev.mjs @@ -53,7 +53,7 @@ const owner = model.symbols.find((symbol) => symbol.id === 'pane.Pane.capture') --- `) -for (const port of ['kotlin', 'lua']) { +for (const port of ['py', 'kotlin', 'scala', 'lua']) { const directory = join(root, `src/pages/${port}/latest/reference`) mkdirSync(directory, { recursive: true }) writeFileSync(join(directory, '[...slug].astro'), `--- @@ -143,12 +143,82 @@ async function checkReferenceAndHeroes(browser, base) { await page.setViewportSize({ width, height: 900 }) assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `Kotlin parameter and exception fields fit at ${width}px`) + const namespace = await page.locator('.api-qualified-namespace').boundingBox() + const copy = await page.getByRole('button', { name: 'Copy fully qualified name' }).boundingBox() + const row = await page.locator('.api-qualified-name').boundingBox() + const title = await page.locator('.api-reference-heading h1').boundingBox() + assert(Math.abs(namespace.y + namespace.height / 2 - copy.y - copy.height / 2) < 2, + `Package and copy control share a center line at ${width}px`) + assert(row.height <= 32 && row.y - title.y - title.height <= 8, + `Package stays in one compact row beneath the heading at ${width}px`) } for (const name of ['config', 'defaultTimeout', 'toKotlinDuration', 'Duration', 'CoroutineScope', 'StateFlow']) { assert(await page.locator('main a').filter({ hasText: new RegExp(`^${name}$`) }).count() > 0, `Kotlin liveState links ${name}`) } console.log('Native API: Kotlin call-chain links and parameter layout pass at 1440/768/390px') + const qualified = page.locator('.api-qualified-name') + const fullName = 'io.github.libtmux.kotlin.Server.liveState' + assert.equal((await qualified.locator('.api-qualified-namespace').textContent()).trim(), 'io.github.libtmux.kotlin') + for (const reject of [false, true]) { + await page.evaluate((reject) => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { + writeText: async (text) => { + window.__copiedQualifiedName = text + if (reject) throw new Error('Clipboard denied') + }, + } }) + }, reject) + await qualified.getByRole('button', { name: 'Copy fully qualified name' }).click() + await page.waitForFunction((reject) => document.querySelector('.api-copy-status').textContent + .startsWith(reject ? 'Copy failed' : 'Name copied'), reject) + assert.equal(await page.evaluate(() => window.__copiedQualifiedName), fullName) + } + const anchors = await page.locator('[id]').evaluateAll((elements) => elements.map((element) => element.id)) + assert.equal(anchors.filter((id) => id === fullName).length, 1, 'Page title and declaration have distinct anchors') + console.log('Qualified names: compact package row, exact copied identity and clipboard refusal pass') + await page.setViewportSize({ width: 1440, height: 900 }) + const contents = page.getByRole('navigation', { name: 'On this page', exact: true }) + assert(await contents.isVisible(), 'Wide reference pages show section navigation') + assert.deepEqual(await page.locator('[data-api-section-link]').evaluateAll((links) => links + .map((link) => decodeURIComponent(link.hash.slice(1))) + .filter((id) => [...document.querySelectorAll('[id]')].filter((element) => element.id === id).length !== 1)), [], + 'Every reference section link has exactly one target') + await contents.getByRole('link', { name: 'Parameters', exact: true }).click() + await page.waitForFunction(() => document.querySelector('api-page-contents a[aria-current="location"]')?.textContent === 'Parameters') + await page.goto(`${base}/kotlin/latest/reference/io-github-libtmux-kotlin-server/`) + const memberNames = await page.locator('.api-member-link').allTextContents() + assert.deepEqual(memberNames.slice(0, 3), ['sessions', 'windows', 'panes']) + assert.equal(new Set(memberNames).size, memberNames.length, 'Grouped members appear once') + assert(await page.getByRole('navigation', { name: 'Related APIs' }).getByRole('link', { name: 'io.github.libtmux.kotlin.Session', exact: true }).count()) + for (const width of [1440, 768, 390]) { + await page.setViewportSize({ width, height: 900 }) + assert.equal(await page.locator('api-page-contents').isVisible(), width >= 1360) + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `Reference columns fit at ${width}px`) + } + console.log('Reference contents: section targets, active links, related declarations and grouped member order pass') + await page.goto(`${base}/scala/latest/reference/io-github-libtmux-scaladsl-server-windows/`) + const signature = page.locator('dt.api-native-header').first() + const links = await signature.locator('.api-native-signature a').evaluateAll((elements) => + elements.map((element) => ({ name: element.textContent, href: element.getAttribute('href') }))) + for (const name of ['def', 'extension', 'self', 'windows', 'id', 'expression']) { + assert(!links.some((link) => link.name === name), `${name} is a declaration token, not an API link`) + } + for (const name of ['Server', 'Window', 'io.github.libtmux.WindowId']) { + assert(links.some((link) => link.name === name), `Scala signature links ${name}`) + } + assert(links.filter((link) => link.name === 'Vector').every((link) => + link.href === 'https://www.scala-lang.org/api/3.x/scala/collection/immutable/Vector.html'), + 'Scala Vector links to its own collection type') + for (const width of [1440, 768, 390]) { + await page.setViewportSize({ width, height: 900 }) + const header = await signature.boundingBox() + const code = await signature.locator('.gp-sphinx-api-layout-left:visible, .gp-sphinx-api-layout-bottom:visible').boundingBox() + assert(code.width >= header.width - 36, `Scala overloads use the signature width at ${width}px`) + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + `Scala overloads fit at ${width}px`) + } + console.log('Scala overloads: only API symbols link, Vector resolves to Scala, and badges leave the full code width') } finally { await page.close() } @@ -287,12 +357,14 @@ try { } }) for (const path of [ + 'py/latest/reference/libtmux-server', 'py/stable/workspace/reference/tmuxp-workspace-builder-classicworkspacebuilder', 'java/latest/workspace/reference/io-github-libtmux-workspace-workspacebuilder-workspacebuilder', ]) await retryReload(async () => { const response = await page.goto(`${base}/${path}/`, { waitUntil: 'load' }) assert(response?.ok(), `${path}: HTTP ${response?.status()}`) await page.evaluate(() => document.fonts.ready) + await page.locator('.api-elsewhere').evaluateAll((entries) => entries.forEach((entry) => { entry.open = true })) for (const width of [1440, 768, 390]) { await page.setViewportSize({ width, height: 1000 }) const overflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth) @@ -371,11 +443,22 @@ try { } console.log('Reading colors: Python, Swift, Go and C++ share neutral light/dark text and surfaces') for (const colorScheme of ['light', 'dark']) { - const context = await browser.newContext({ javaScriptEnabled: false, colorScheme }) + await page.setViewportSize({ width: 390, height: 844 }) + await page.emulateMedia({ colorScheme }) + await page.goto(`${base}/concepts/server-session-window-pane/`, { waitUntil: 'load' }) + const toolbar = await page.evaluate(() => { + delete document.documentElement.dataset.themeMode + const style = (selector) => getComputedStyle(document.querySelector(selector)) + return { background: style('.mobile-toolbar').backgroundColor, page: style('body').backgroundColor, + icon: style('.toolbar-button').color, text: style('body').color } + }) + assert.equal(toolbar.background, toolbar.page, `${colorScheme}: toolbar uses the page surface before theme initialization`) + assert.equal(toolbar.icon, toolbar.text, `${colorScheme}: toolbar icons use the page text color`) + const context = await browser.newContext({ javaScriptEnabled: false, colorScheme, viewport: { width: 390, height: 844 } }) const noScript = await context.newPage() await noScript.goto(`${base}/concepts/server-session-window-pane/`, { waitUntil: 'load' }) - const checkContrast = async (scheme) => { - const samples = await noScript.evaluate(() => { + const checkContrast = async (scheme, selectors = ['h1', '.prose h2', '.prose p']) => { + const samples = await noScript.evaluate((selectors) => { const context = document.createElement('canvas').getContext('2d') const luminance = (color) => { context.fillStyle = color @@ -384,22 +467,40 @@ try { .map((v) => v / 255).map((v) => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4) return channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722 } - return ['h1', '.prose h2', '.prose p'].map((selector) => { + return selectors.map((selector) => { const element = document.querySelector(selector) let parent = element while (getComputedStyle(parent).backgroundColor === 'rgba(0, 0, 0, 0)') parent = parent.parentElement const values = [getComputedStyle(element).color, getComputedStyle(parent).backgroundColor] .map(luminance).sort((a, b) => b - a) - return { selector, contrast: (values[0] + .05) / (values[1] + .05) } + return { selector, contrast: (values[0] + .05) / (values[1] + .05), opacity: getComputedStyle(element).opacity } }) - }) - for (const sample of samples) assert(sample.contrast >= 4.5, - `No-JS ${scheme} ${sample.selector} contrast: ${sample.contrast}`) + }, selectors) + for (const sample of samples) { + assert(sample.contrast >= 4.5, `No-JS ${scheme} ${sample.selector} contrast: ${sample.contrast}`) + assert.equal(sample.opacity, '1', `${scheme} ${sample.selector} remains fully legible`) + } } await checkContrast(colorScheme) + assert.equal(await noScript.locator('.mobile-toolbar').isVisible(), false, 'No-JS hides inactive drawer buttons') + assert.equal(await noScript.locator('.mobile-fallback').isVisible(), true, 'No-JS has usable mobile navigation') + await checkContrast(colorScheme, ['.mobile-fallback summary']) + const browse = noScript.locator('.mobile-fallback > details').first() + await browse.locator('summary').first().focus() + await noScript.keyboard.press('Enter') + assert.equal(await browse.getAttribute('open'), '', 'Keyboard opens the native navigation disclosure') + assert.equal(await browse.locator('a:visible').count() > 0, true, 'The navigation disclosure exposes links') + await browse.locator('summary').first().click() + const contents = noScript.locator('.mobile-fallback > details').nth(1) + await contents.locator('summary').click() + const destination = await contents.locator('a').first().getAttribute('href') + await contents.locator('a').first().click() + assert.equal(new URL(noScript.url()).hash, destination, 'No-JS contents reaches its section') const override = colorScheme === 'dark' ? 'light' : 'dark' await noScript.evaluate((mode) => { document.documentElement.dataset.themeMode = mode }, override) await checkContrast(`${colorScheme} with ${override} override`) + await noScript.goto(`${base}/scala/latest/reference/io-github-libtmux-scaladsl-server-windows/`) + await checkContrast(colorScheme, ['.api-native-signature .api-type-label', '.api-native-signature .api-punct']) await context.close() } console.log('Fresh Astro + browser: prose, workspace, MCP tools, API equivalents, 390–1600px header and dark hue PASS') diff --git a/site/scripts/check-navigation.mjs b/site/scripts/check-navigation.mjs index fdfa35aa..eb002dd4 100644 --- a/site/scripts/check-navigation.mjs +++ b/site/scripts/check-navigation.mjs @@ -81,6 +81,45 @@ export async function checkApiNavigation(page, base) { assert.equal(await page.locator('#api-nav').evaluate((el) => el.inert), false) assert.equal(await page.locator('[data-api-nav-toggle]').isVisible(), false) console.log('API navigation: client swaps, Back, drawer close controls, disclosure alignment and desktop pass') + + const search = page.getByRole('searchbox', { name: 'Find a type or member' }) + const results = page.locator('[data-api-search-results]') + await search.fill('Server:snapshot') + await results.locator('a[href$="libtmux-server-snapshot/"]').waitFor() + assert.equal(await page.locator('[role="tree"]').isVisible(), false) + await page.locator('[data-api-search-kind="types"]').click() + await page.waitForFunction(() => document.querySelector('[data-api-search-status]').textContent === '0 results') + await page.locator('[data-api-search-kind="members"]').click() + await results.locator('a[href$="libtmux-server-snapshot/"]').waitFor() + await search.fill('Server snapshot') + await results.locator('a[href$="libtmux-server-snapshot/"]').waitFor() + await search.focus() + await page.keyboard.press('ArrowDown') + assert(await results.locator('a').first().evaluate((link) => link === document.activeElement)) + await page.keyboard.press('End') + assert(await results.locator('a').last().evaluate((link) => link === document.activeElement)) + await page.keyboard.press('Home') + assert(await results.locator('a').first().evaluate((link) => link === document.activeElement)) + await page.keyboard.press('Escape') + assert(await search.evaluate((input) => input === document.activeElement)) + await page.keyboard.press('Escape') + assert.equal(await search.inputValue(), '') + assert(await page.locator('[role="tree"]').isVisible()) + await page.setViewportSize({ width: 390, height: 759 }) + await page.locator('[data-api-nav-toggle]').click() + await search.fill('Server:snapshot') + await results.locator('a[href$="libtmux-server-snapshot/"]').waitFor() + assert.equal(await page.locator('[role="tree"]').isVisible(), false, 'Phone search replaces the tree') + await page.locator('[data-api-nav-close]').click() + await page.route('**/tree.json', (route) => route.fulfill({ status: 503, body: 'Unavailable' })) + await page.reload() + await page.locator('[data-api-nav-toggle]').click() + await search.fill('Server:snapshot') + await page.waitForFunction(() => document.querySelector('[data-api-search-status]').textContent.includes('could not load')) + await page.unroute('**/tree.json') + await page.getByRole('button', { name: 'Retry search' }).click() + await results.locator('a[href$="libtmux-server-snapshot/"]').waitFor() + console.log('API search: full member names, category filters, keyboard focus, phone layout and visible retryable failures pass') } /** Check the controls attached to a document after its content is replaced. */ diff --git a/site/src/components/MobileToC.astro b/site/src/components/MobileToC.astro index 5aafb4e1..1dfca629 100644 --- a/site/src/components/MobileToC.astro +++ b/site/src/components/MobileToC.astro @@ -13,7 +13,7 @@ const { tocItems } = Astro.props -