diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index 0a76c8d..c3d68b9 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -41,9 +41,9 @@ jobs: key: pagefind-binary-v1-${{ runner.os }}-${{ runner.arch }}-${{ env.PAGEFIND_REVISION }} - uses: actions/setup-node@v7 - if: steps.pagefind-cache.outputs.cache-hit != 'true' with: node-version: 24 + cache: npm - name: Install Pagefind Rust toolchain if: steps.pagefind-cache.outputs.cache-hit != 'true' @@ -63,7 +63,6 @@ jobs: bundler-cache: true - name: Installing packages - if: steps.pagefind-cache.outputs.cache-hit != 'true' run: sudo apt-get install wget - name: Build Pagefind fork @@ -76,6 +75,29 @@ jobs: env: PAGEFIND_BINARY_PATH: ${{ github.workspace }}/.pagefind/target/release/pagefind run: bundle exec bake utopia:project:static --force no + + - name: Install browser test dependencies + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Build browser fixture + timeout-minutes: 5 + env: + PAGEFIND_BINARY_PATH: ${{ github.workspace }}/.pagefind/target/release/pagefind + run: bundle exec ruby fixtures/utopia/project/build_site.rb + + - name: Test generated documentation in Chromium + timeout-minutes: 5 + run: npm run test:browser + + - name: Upload browser failure diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: browser-test-results + path: test-results + if-no-files-found: ignore - name: Upload documentation artifact uses: actions/upload-pages-artifact@v5 diff --git a/.gitignore b/.gitignore index 8410daf..382f9a1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ /node_modules /.github/workflows/test-external.yaml + +/test/browser/.site +/test-results +/playwright-report diff --git a/bake/utopia/project.rb b/bake/utopia/project.rb index 3d47b4a..2f99df8 100644 --- a/bake/utopia/project.rb +++ b/bake/utopia/project.rb @@ -121,8 +121,6 @@ def description(root: context.root) child = document.first_child if child&.type == :header - title = child.first_child.string_content - # First sentence if introduction = child.next $stdout.puts introduction.to_plaintext[/.*?\./] diff --git a/bake/utopia/project/agent/context.rb b/bake/utopia/project/agent/context.rb index 38df190..ed820b6 100644 --- a/bake/utopia/project/agent/context.rb +++ b/bake/utopia/project/agent/context.rb @@ -24,7 +24,7 @@ def update files << { "path" => guide.name + ".md", "title" => guide.title, - "description" => guide.description.to_markdown.chomp, + "description" => guide.description&.to_markdown&.chomp || "", } end diff --git a/config/covered.rb b/config/covered.rb new file mode 100644 index 0000000..80b7161 --- /dev/null +++ b/config/covered.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +# Include exported tasks and controllers even when no test loads them. +def include_patterns + super + ["bake/**/*.rb", "pages/**/*.rb"] +end diff --git a/fixtures/utopia/project/build_site.rb b/fixtures/utopia/project/build_site.rb new file mode 100644 index 0000000..3a17344 --- /dev/null +++ b/fixtures/utopia/project/build_site.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "bake/context" +require "fileutils" + +root = File.expand_path("../../../test/utopia/project/.fixtures/site", __dir__) +output = File.expand_path("../../../test/browser/.site", __dir__) + +Dir.chdir(root) do + Bake::Context.load(root)["utopia:project:static"].call(output_path: output) +end diff --git a/fixtures/utopia/project/site.rb b/fixtures/utopia/project/site.rb new file mode 100644 index 0000000..cae501b --- /dev/null +++ b/fixtures/utopia/project/site.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "tmpdir" +require "fileutils" +require "utopia/project" +require_relative "../../../template/config/environment" +require "sus/fixtures/protocol/http/middleware_context" + +module Utopia + module Project + module SiteContext + include Sus::Fixtures::Protocol::HTTP::MiddlewareContext + + SITE = File.expand_path("../../../test/utopia/project/.fixtures/site", __dir__) + + def around(&block) + previous = Thread.current.thread_variable_get(Base.name) + Dir.mktmpdir("utopia-project-test") do |root| + @root = root + FileUtils.cp_r("#{SITE}/.", root) + Base.instance = base + super(&block) + end + ensure + Base.instance = previous + end + + def base + @base ||= Base.new(@root).tap do |base| + base.update(Dir.glob("#{@root}/lib/**/*.rb")) + end + end + + def middleware + @middleware ||= Utopia::Application.build do |builder| + Project.call(builder, @root) + end + end + + def write(path, content) + path = File.join(@root, path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + end + end +end diff --git a/lib/utopia/project/base.rb b/lib/utopia/project/base.rb index 25de00f..6507d5e 100644 --- a/lib/utopia/project/base.rb +++ b/lib/utopia/project/base.rb @@ -107,12 +107,12 @@ def lookup(path) # @parameter definition [Decode::Definition] The definition to load documentation for. # @returns [Document | Nil] The supplemental document, if it exists. def document_for(definition) - document_path = File.join("lib", definition.lexical_path.map{|_| _.to_s.downcase}) + ".md" + document_path = File.join(@root, "lib", definition.lexical_path.map{|_| _.to_s.downcase}) + ".md" if File.exist?(document_path) document = self.document(File.read(document_path), definition) - if document.first_child.type == :header + if document.first_child&.type == :header document.first_child.delete end diff --git a/lib/utopia/project/document.rb b/lib/utopia/project/document.rb index 0ad4efe..6827695 100644 --- a/lib/utopia/project/document.rb +++ b/lib/utopia/project/document.rb @@ -33,12 +33,12 @@ def root end # Extract the leading heading as the document title. - # @returns [String | Nil] The title, if the document starts with a heading. + # @returns [String | Nil] The title, if the document starts with a non-empty heading. def title child = self.root.first_child if child && child.type == :header - return child.first_child.to_plaintext + return child.first_child&.to_plaintext end end @@ -61,7 +61,7 @@ def replace_section(name, children: false) header = child # We found the matched header: - if header.first_child.to_plaintext.include?(name) + if header.first_child&.to_plaintext&.include?(name) # Now subsequent children: current = header.next diff --git a/lib/utopia/project/guide.rb b/lib/utopia/project/guide.rb index 5f15e3b..c3a2e4e 100644 --- a/lib/utopia/project/guide.rb +++ b/lib/utopia/project/guide.rb @@ -44,32 +44,11 @@ def order metadata[:order] end - # Compare guides by explicit order and then by name. + # Compare guides by order (defaulting to zero) and then by name. # @parameter other [Guide] The other guide to compare. # @returns [Integer] The comparison result. def <=> other - if order = self.order - if other_order = other.order - if order < other_order - return -1 - elsif order > other_order - return 1 - end - else - # If we have order, but the other doesn't, we come first: - return -1 - end - end - - if name = self.name - if other_name = other.name - return name <=> other_name - else - return -1 - end - end - - return 0 + [self.order || 0, self.name] <=> [other.order || 0, other.name] end README = "readme.md" @@ -93,7 +72,7 @@ def document child = document.first_child if child&.type == :header - @title = child.first_child.string_content + @title = child.first_child&.string_content @description = child.next child.delete diff --git a/package-lock.json b/package-lock.json index 998f42a..829b6a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "dependencies": { "@socketry/syntax": "^0.6.2", "mermaid": "^11.16.1" + }, + "devDependencies": { + "@playwright/test": "1.63.0" } }, "node_modules/@antfu/install-pkg": { @@ -74,6 +77,22 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@socketry/syntax": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/@socketry/syntax/-/syntax-0.6.2.tgz", @@ -1158,6 +1177,35 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", diff --git a/package.json b/package.json index e5ba456..07566d8 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,11 @@ } } } + }, + "devDependencies": { + "@playwright/test": "1.63.0" + }, + "scripts": { + "test:browser": "playwright test" } } diff --git a/pages/guides/controller.rb b/pages/guides/controller.rb index fe12ffa..759b9f9 100644 --- a/pages/guides/controller.rb +++ b/pages/guides/controller.rb @@ -12,5 +12,7 @@ guide.name == name end + respond! Utopia::Response[404] unless @guide + path.components = ["show"] end diff --git a/pages/index.xnode b/pages/index.xnode index 77c1e71..e9cc8a4 100644 --- a/pages/index.xnode +++ b/pages/index.xnode @@ -3,12 +3,12 @@ if document = self[:document] child = document.first_child - if child.type == :header + if child&.type == :header header = child child.delete title = header.first_child - case title.type + case title&.type when :text ?>#{title.string_content} - \ No newline at end of file + diff --git a/pages/reference/controller.rb b/pages/reference/controller.rb index 2cf0012..e4df623 100644 --- a/pages/reference/controller.rb +++ b/pages/reference/controller.rb @@ -13,7 +13,7 @@ @node, @symbol = @base.lookup(@lexical_path) unless @symbol - fail! :not_found + respond! Utopia::Response[404] end path.components = ["show"] diff --git a/playwright.config.cjs b/playwright.config.cjs new file mode 100644 index 0000000..6cf8de5 --- /dev/null +++ b/playwright.config.cjs @@ -0,0 +1,24 @@ +const {defineConfig} = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './test/browser', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 2, + use: { + baseURL: 'http://127.0.0.1:9294/project/', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ? {executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH} : {}, + }, + projects: ['light', 'dark'].flatMap(colorScheme => [390, 1440].map(width => ({ + name: `${colorScheme}-${width}`, + use: {browserName: 'chromium', colorScheme, viewport: {width, height: 900}}, + }))), + webServer: { + command: 'node test/browser/server.cjs', + url: 'http://127.0.0.1:9294/project/index.html', + reuseExistingServer: false, + }, +}); diff --git a/releases.md b/releases.md index efda0b7..f0cad3e 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,11 @@ ## Unreleased + - Sort guides by order (defaulting to zero), then name. + - Fix supplemental documentation paths and missing guide/reference responses. + - Handle empty READMEs and guides without descriptions when rendering pages and generating agent context. + - Treat empty Markdown headings as missing titles and preserve the following content when rendering pages or updating documentation. + - Cover all Ruby, task, and rendered template lines, and exercise the generated site in Chromium at mobile and desktop widths in light and dark mode. - Add padding to documentation table cells and allow tables to scroll whenever they exceed the available width. - Scale table, inline code, badge, navigation link, and disclosure spacing with the local font size. diff --git a/test/browser/readme.md b/test/browser/readme.md new file mode 100644 index 0000000..faa411c --- /dev/null +++ b/test/browser/readme.md @@ -0,0 +1,23 @@ +# Browser Tests + +These tests use the exported fixture project in `test/utopia/project/.fixtures/site`. They exercise navigation, search, diagrams, syntax highlighting, keyboard disclosures, and table layout at mobile and desktop widths in light and dark mode. The static server mounts the site at `/project/` to check GitHub Pages subpath handling. + +The link check fetches each destination page once and verifies decoded fragments against element IDs and named anchors in the exported HTML. This includes duplicate headings and cross-page Ruby method references. + +Install the bundle with the maintenance group enabled, then install the browser dependencies: + +``` sh +npm ci +npx playwright install chromium +``` + +Build the Pagefind fork using the revision and setup steps in `.github/workflows/documentation.yaml`. Set `PAGEFIND_BINARY_PATH` to its executable, then build the fixture and run the tests: + +``` sh +bundle exec ruby fixtures/utopia/project/build_site.rb +npm run test:browser +``` + +Alternatively, set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to an existing Chromium executable. Failures save screenshots and Playwright traces in `test-results/`. + +CI runs these checks in the documentation workflow, using the same Pagefind build as the published documentation. Browser checks do not contribute to the Ruby line coverage percentage. diff --git a/test/browser/server.cjs b/test/browser/server.cjs new file mode 100644 index 0000000..991d144 --- /dev/null +++ b/test/browser/server.cjs @@ -0,0 +1,22 @@ +const http = require('node:http'); +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '.site'); +const types = {'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml'}; + +http.createServer(async (request, response) => { + try { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); + if (!pathname.startsWith('/project/')) throw new Error('Outside project'); + const relative = pathname.slice('/project/'.length); + const filename = path.resolve(root, pathname.endsWith('/') ? relative + 'index.html' : relative); + if (!filename.startsWith(root + path.sep)) throw new Error('Outside fixture'); + const content = await fs.readFile(filename); + response.writeHead(200, {'content-type': types[path.extname(filename)] || 'application/octet-stream'}); + response.end(content); + } catch { + response.writeHead(404); + response.end('Not found'); + } +}).listen(9294, '127.0.0.1'); diff --git a/test/browser/site.spec.cjs b/test/browser/site.spec.cjs new file mode 100644 index 0000000..85341ce --- /dev/null +++ b/test/browser/site.spec.cjs @@ -0,0 +1,130 @@ +const {test, expect} = require('@playwright/test'); +const guide = 'guides/getting-started/index.html'; + +test('renders diagrams, highlighted code and local links under a project subpath', async ({page, request}) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(guide); + await expect(page.locator('.mermaid svg')).toBeVisible(); + await expect(page.locator('syntax-code')).not.toHaveCount(0); + await expect(page.locator('#configuration-2')).toHaveCount(1); + const links = await page.locator('a[href]').evaluateAll(links => links.map(link => link.href).filter(href => new URL(href).origin === location.origin)); + const targetsByPage = new Map(); + for (const href of new Set(links)) { + const url = new URL(href); + expect(url.pathname).toMatch(/^\/project\//); + const fragment = decodeURIComponent(url.hash.slice(1)); + url.hash = ''; + if (!targetsByPage.has(url.href)) { + const response = await request.get(url.href); + expect(response.ok(), url.href).toBeTruthy(); + const targets = await page.evaluate(html => { + const document = new DOMParser().parseFromString(html, 'text/html'); + return [ + ...Array.from(document.querySelectorAll('[id]'), element => element.id), + ...Array.from(document.querySelectorAll('a[name]'), element => element.name), + ]; + }, await response.text()); + targetsByPage.set(url.href, new Set(targets)); + } + if (fragment) { + expect(targetsByPage.get(url.href).has(fragment), `Missing fragment target: ${href}`).toBeTruthy(); + } + } + expect(errors).toEqual([]); +}); + +test('preserves deep links and tracks sidebar navigation', async ({page}, testInfo) => { + await page.goto(guide + '#configuration-2'); + await expect(page.locator('a.self')).not.toHaveCount(0); + await expect(page).toHaveURL(/#configuration-2$/); + if (testInfo.project.use.viewport.width < 1024) { + await expect(page.locator('.sidebar')).toBeHidden(); + return; + } + const link = page.locator('.sidebar a[href$="#configuration"]'); + await link.click(); + await expect(link).toBeFocused(); + await expect(link).toHaveClass(/active/); + await expect(page).toHaveURL(/#configuration$/); + await page.locator('#deployment').evaluate(element => element.scrollIntoView()); + await expect(page.locator('.sidebar a[href$="#deployment"]')).toHaveClass(/active/); + await expect(page).toHaveURL(/#deployment$/); +}); + +test('contains wide tables and scales spacing with table text', async ({page}) => { + await page.goto(guide); + const table = page.locator('table'); + const padding = []; + for (const size of ['80%', '125%']) { + padding.push(await table.evaluate((table, size) => { + table.style.fontSize = size; + const style = getComputedStyle(table.querySelector('td')); + return {font: parseFloat(style.fontSize), top: parseFloat(style.paddingTop), left: parseFloat(style.paddingLeft)}; + }, size)); + } + expect(padding[1].top / padding[0].top).toBeCloseTo(padding[1].font / padding[0].font); + expect(padding[1].left / padding[0].left).toBeCloseTo(padding[1].font / padding[0].font); + const backgrounds = await table.locator('tbody tr').first().locator('td').evaluateAll(cells => cells.map(cell => getComputedStyle(cell).backgroundColor)); + expect(backgrounds[1]).not.toEqual(backgrounds[0]); + expect(backgrounds[2]).toEqual(backgrounds[0]); + await table.locator('td').first().evaluate(cell => cell.textContent = 'LONG_CONFIGURATION_NAME_'.repeat(30)); + const scrolling = await table.evaluate(table => { + table.scrollLeft = 100; + return {offset: table.scrollLeft, pageWidth: document.documentElement.scrollWidth, viewport: innerWidth}; + }); + expect(scrolling.offset).toBe(100); + expect(scrolling.pageWidth).toBe(scrolling.viewport); +}); + +test('opens example disclosures with the keyboard without shifting their summaries', async ({page}) => { + await page.goto('reference/Example/Client/index.html'); + const details = page.locator('details').first(); + await details.evaluate(element => element.style.fontSize = '125%'); + const summary = details.locator('summary'); + await summary.focus(); + const before = await summary.boundingBox(); + await summary.press('Enter'); + await expect(details).toHaveAttribute('open', ''); + await expect(details.locator('pre')).toBeVisible(); + const after = await summary.boundingBox(); + expect(after.x).toBeCloseTo(before.x); + expect(after.width).toBeCloseTo(before.width); + await summary.press('Space'); + await expect(details).not.toHaveAttribute('open'); +}); + +test('hides unavailable search without breaking navigation', async ({page}) => { + await page.route('**/pagefind-component-ui.js', route => route.abort()); + const unavailable = page.waitForEvent('console', message => message.text().includes('Documentation search is unavailable.')); + await page.goto(guide); + await unavailable; + await expect(page.locator('a.self')).not.toHaveCount(0); + await expect(page.locator('pagefind-modal-trigger')).toBeHidden(); + await page.locator('.section-links a').filter({hasText: 'Reference'}).click(); + await expect(page).toHaveURL(/reference\/index.html$/); +}); + +test('searches the generated index and follows results under the project subpath', async ({page}) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('index.html'); + const trigger = page.locator('pagefind-modal-trigger button'); + await trigger.click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const input = dialog.locator('input'); + await expect(input).toBeFocused(); + await input.fill('preview'); + const result = dialog.locator('pagefind-results a[href*="/guides/getting-started/"]').first(); + await expect(result).toBeVisible(); + await expect(result).toHaveAttribute('href', /^\/project\/guides\/getting-started\//); + await result.click(); + await expect(page).toHaveURL(/\/project\/guides\/getting-started\/(?:index\.html)?(?:#.*)?$/); + await expect(page.locator('h1')).toHaveText('Getting Started'); + await page.locator('pagefind-modal-trigger button').click(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(page.locator('pagefind-modal-trigger button')).toBeFocused(); + expect(errors).toEqual([]); +}); diff --git a/test/utopia/project/.fixtures/site/example.gemspec b/test/utopia/project/.fixtures/site/example.gemspec new file mode 100644 index 0000000..f5255ac --- /dev/null +++ b/test/utopia/project/.fixtures/site/example.gemspec @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = "example" + spec.version = "1.0.0" + spec.summary = "Example documentation." + spec.authors = ["Example"] + spec.homepage = "https://example.com/project/" + spec.metadata["documentation_uri"] = "https://example.com/project/" + spec.metadata["source_code_uri"] = "https://github.com/example/project" +end diff --git a/test/utopia/project/.fixtures/site/guides/empty/readme.md b/test/utopia/project/.fixtures/site/guides/empty/readme.md new file mode 100644 index 0000000..847c3af --- /dev/null +++ b/test/utopia/project/.fixtures/site/guides/empty/readme.md @@ -0,0 +1 @@ +# Empty Guide diff --git a/test/utopia/project/.fixtures/site/guides/getting-started/readme.md b/test/utopia/project/.fixtures/site/guides/getting-started/readme.md new file mode 100644 index 0000000..07fb335 --- /dev/null +++ b/test/utopia/project/.fixtures/site/guides/getting-started/readme.md @@ -0,0 +1,39 @@ +# Getting Started + +This guide explains how to preview the example project. + + + +## Installation + +Install the project before running the examples. + +~~~ ruby +Example::Client.new +~~~ + +## Usage + +| Task | Command | Result | +| --- | --- | --- | +| Preview | `bake utopia:project:serve` | Serve documentation while editing guides. | +| Build | `bake utopia:project:static` | Generate documentation for static hosting. | + +~~~ mermaid +flowchart LR + Source --> Documentation +~~~ + +### Configuration + +See ruby:`Example::Client#call`. + +## Deployment + +Publish the generated documentation. + +### Configuration + +Check links after deployment. + +Return to the [overview](#overview) or the [top of the page](#). diff --git a/test/utopia/project/.fixtures/site/guides/links.yaml b/test/utopia/project/.fixtures/site/guides/links.yaml new file mode 100644 index 0000000..0af65c7 --- /dev/null +++ b/test/utopia/project/.fixtures/site/guides/links.yaml @@ -0,0 +1,6 @@ +getting-started: + order: 1 +source-example: + order: 2 +empty: + order: 3 diff --git a/test/utopia/project/.fixtures/site/guides/source-example/example.rb b/test/utopia/project/.fixtures/site/guides/source-example/example.rb new file mode 100644 index 0000000..41aba5d --- /dev/null +++ b/test/utopia/project/.fixtures/site/guides/source-example/example.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# This example explains how to call the client. +Example::Client.new.call("hello") + +puts "done" diff --git a/test/utopia/project/.fixtures/site/lib/example.rb b/test/utopia/project/.fixtures/site/lib/example.rb new file mode 100644 index 0000000..f392d4b --- /dev/null +++ b/test/utopia/project/.fixtures/site/lib/example.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Example project namespace. +module Example + # Adds instrumentation. + module Logging + # Record an event. + def log + end + end + + # Adds metrics. + module Metrics + # Count requests. + def count + end + end + + # Sends requests. + class Client + include Logging + include Metrics + + # Send a request. + # @parameter message [String] The request text. + # @returns [String] The response. + # @asynchronous + # @example Send a message + # Client.new.call("hello") + # @example + # Client.new.call("goodbye") + def call(message) + message + end + end +end diff --git a/test/utopia/project/.fixtures/site/lib/example/client.md b/test/utopia/project/.fixtures/site/lib/example/client.md new file mode 100644 index 0000000..a5c47ed --- /dev/null +++ b/test/utopia/project/.fixtures/site/lib/example/client.md @@ -0,0 +1,7 @@ +# Client Details + +Supplemental documentation for the client. + +## Requests + +Call ruby:`Example::Client#call` to send a request. diff --git a/test/utopia/project/.fixtures/site/readme.md b/test/utopia/project/.fixtures/site/readme.md new file mode 100644 index 0000000..abfb1e5 --- /dev/null +++ b/test/utopia/project/.fixtures/site/readme.md @@ -0,0 +1,11 @@ +# Example Project + +Documentation for a small project. + +## Usage + +This section is generated. + +## Releases + +This section is generated. diff --git a/test/utopia/project/.fixtures/site/releases.md b/test/utopia/project/.fixtures/site/releases.md new file mode 100644 index 0000000..c54a91b --- /dev/null +++ b/test/utopia/project/.fixtures/site/releases.md @@ -0,0 +1,17 @@ +# Changes + +## v1.1.0 + +Improved documentation. + +### Code Examples + +Examples now have titles. + +#### Details + +Nested release sections stay with their parent. + +## v1.0.0 + +First release. diff --git a/test/utopia/project/base.rb b/test/utopia/project/base.rb new file mode 100644 index 0000000..4577fae --- /dev/null +++ b/test/utopia/project/base.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "utopia/project/site" + +describe Utopia::Project::Base do + include Utopia::Project::SiteContext + + it "loads supplemental documentation from the project root" do + _, definition = base.lookup(%w[Example Client]) + document = base.document_for(definition) + + expect(document).not.to be_nil + expect(document.to_html).to be(:include?, "Supplemental documentation") + expect(document.to_html).not.to be(:include?, "Client Details") + end + + it "handles empty supplemental documents" do + write("lib/example/client.md", "") + _, definition = base.lookup(%w[Example Client]) + + expect(base.document_for(definition).to_html.to_s).to be == "" + end + + it "accepts absent and enumerable documentation" do + expect(base.document(nil)).to be_nil + expect(base.format(nil)).to be_nil + expect(base.document(["First paragraph.", "", "Second paragraph."]).to_html).to be(:include?, "

Second paragraph.

") + end + + it "gives alternative definitions distinct identifiers" do + _, definition = base.lookup(%w[Example Client]) + expect(base.id_for(definition, "alternate")).to be == "Example::Client-alternate" + end + + it "uses source metadata and falls back to the homepage or no source link" do + expect(base.source_code_uri).to be == "https://github.com/example/project" + base.gemspec.metadata.delete("source_code_uri") + expect(base.source_code_uri).to be == "https://example.com/project/" + base.gemspec.homepage = nil + expect(base.source_code_uri).to be_nil + end +end diff --git a/test/utopia/project/changes_document.rb b/test/utopia/project/changes_document.rb index b964537..491c8f5 100644 --- a/test/utopia/project/changes_document.rb +++ b/test/utopia/project/changes_document.rb @@ -21,3 +21,23 @@ expect(names).to be(:include?, "v0.28.0") end end + +describe Utopia::Project::ReleasesDocument do + it "finds releases and keeps nested change sections within their release" do + document = subject.new("# Changes\n\n## v2.0\n\nNew release.\n\n### New Feature\n\n#### Details\n\nText.\n\n## v1.0\n\nOld release.\n") + release = document.latest_release + + expect(release.name).to be == "v2.0" + expect(release.notes.to_markdown).to be == "New release.\n" + expect(release.changes.map(&:to_markdown)).to be == ["New Feature"] + expect(release.changes.map(&:id)).to be == ["new-feature"] + expect(document.release("missing")).to be_nil + expect(document.releases.map(&:name)).to be == ["v2.0", "v1.0"] + end + + it "handles empty release notes" do + document = subject.new("# Changes") + expect(document.latest_release).to be_nil + expect(document.navigation.to_html.to_s).to be == "" + end +end diff --git a/test/utopia/project/document.rb b/test/utopia/project/document.rb index c7ca758..50715d8 100644 --- a/test/utopia/project/document.rb +++ b/test/utopia/project/document.rb @@ -134,3 +134,53 @@ end end end + +describe Utopia::Project::Document do + it "returns no title for empty headings without losing subsequent content" do + ["#", "##"].each do |heading| + expect(subject.new("#{heading}\n").title).to be_nil + document = subject.new("#{heading}\n\nIntroduction.\n") + expect(document.title).to be_nil + expect(document.to_html).to be(:include?, "

Introduction.

") + end + end + + it "skips empty headings when finding a section to replace" do + document = subject.new("#\n\nIntroduction.\n\n##\n\nKeep this.\n\n## Usage\n\nOld content.\n") + document.replace_section("Usage") do |header| + header.insert_after(document.paragraph_node(document.text_node("New content."))) + end + expect(document.to_markdown).to be == "# \n\nIntroduction.\n\n## \n\nKeep this.\n\n## Usage\n\nNew content.\n" + end + + it "renders Mermaid source safely and keeps ordinary fenced code" do + document = subject.new("~~~ mermaid\nflowchart LR\n A[\"\"] --> B\n~~~\n\n~~~ ruby\nputs 42\n~~~\n") + html = document.to_html.to_s + expect(html).to be(:include?, 'class="mermaid"') + expect(html).to be(:include?, "<text>") + expect(html).to be(:include?, 'class="language-ruby"') + end + + it "builds escaped linked code inside a paragraph" do + document = subject.new("") + code = document.code_node("foo < bar", "ruby") + link = document.link_node("Example", "/example", code) + document.root.append_child(document.paragraph_node(link)) + + expect(document.to_html.to_s).to be == '

foo < bar

' + "\n" + end + + it "resolves a reference that consumes the entire text node" do + base = Utopia::Project::Base.new + document = subject.new("{ruby Missing}", base) + expect(document.to_html.to_s).to be == '

Missing

' + "\n" + end + + it "replaces nested sections without removing the following peer section" do + document = subject.new("## Usage\n\nOld content.\n\n### Example\n\nNested content.\n\n## License\n\nKeep this.\n") + document.replace_section("Usage", children: true) do |header| + header.insert_after(document.paragraph_node(document.text_node("New content."))) + end + expect(document.to_markdown).to be == "## Usage\n\nNew content.\n\n## License\n\nKeep this.\n" + end +end diff --git a/test/utopia/project/guides.rb b/test/utopia/project/guides.rb new file mode 100644 index 0000000..f38a6fe --- /dev/null +++ b/test/utopia/project/guides.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "utopia/project/site" + +describe Utopia::Project::Guides do + include Utopia::Project::SiteContext + + it "sorts by order and then name, treating unspecified orders as zero" do + guides = [ + ["alpha", {}], ["zebra", {order: 1}], ["beta", {order: 1}], + ["gamma", {order: 2}], ["delta", {}], + ["omega", {order: -1}], ["charlie", {order: 0}] + ].map do |name, metadata| + Utopia::Project::Guide.new(base, File.join(@root, "guides", name), metadata) + end + + expect(guides.sort.map(&:name)).to be == ["omega", "alpha", "charlie", "delta", "beta", "zebra", "gamma"] + expect(guides.reverse.sort.map(&:name)).to be == ["omega", "alpha", "charlie", "delta", "beta", "zebra", "gamma"] + end + + it "finds guides and handles navigation boundaries" do + guides = base.guides + first, middle, last = guides.to_a + + expect(guides["getting-started"]).to be_equal(first) + expect(guides["missing"]).to be_nil + expect(guides.related(first)).to be == [nil, middle] + expect(guides.related(middle)).to be == [first, last] + expect(guides.related(last)).to be == [middle, nil] + unknown = Utopia::Project::Guide.new(base, File.join(@root, "missing"), {}) + expect(guides.related(unknown)).to be == [nil, nil] + end + + it "extracts source documentation for a guide without a README" do + guide = base.guides["source-example"] + + expect(guide.readme?).to be == false + expect(guide.title).to be == "Source Example" + expect(guide.documentation.text.join).to be(:include?, "This example explains") + expect(guide.sources.map{|source| File.basename(source.path)}).to be == ["example.rb"] + expect(guide.navigation.any?).to be == false + end + + it "allows guides without an introductory paragraph or source documentation" do + guide = base.guides["empty"] + + expect(guide.title).to be == "Empty Guide" + expect(guide.description).to be_nil + expect(guide.documentation).to be_nil + end +end diff --git a/test/utopia/project/rendering.rb b/test/utopia/project/rendering.rb new file mode 100644 index 0000000..7d45330 --- /dev/null +++ b/test/utopia/project/rendering.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "utopia/project/site" + +describe "Project pages" do + include Utopia::Project::SiteContext + + it "renders source examples and guides without descriptions" do + response = client.get("/guides/source-example/index") + expect(response.status).to be == 200 + expect(response.read).to be(:include?, "This example explains how to call the client.") + body = client.get("/index").read + expect(body).to be(:include?, "No description.") + expect(body).to be(:include?, "This example explains how to call the client.") + end + + it "renders examples, pragmas, multiple relationships and supplemental documentation" do + response = client.get("/reference/Example/Client/index") + body = response.read + + expect(response.status).to be == 200 + expect(body).to be(:include?, "Supplemental documentation for the client.") + expect(body).to be(:include?, "Example: Send a message") + expect(body).to be(:include?, "

Example.

") + expect(body).to be(:include?, 'class="pragma asynchronous"') + expect(body).to be(:include?, "; ") + expect(body).to be(:include?, "/reference/Example/Logging/index") + expect(body).to be(:include?, "/reference/Example/Metrics/index") + end + + it "returns 404 for an unknown reference or guide" do + ["/reference/Missing/index", "/guides/missing/index"].each do |path| + response = client.get(path) + expect(response.status).to be == 404 + expect(response.read).to be(:include?, "File Not Found") + end + end + + it "renders release navigation and the missing releases fallback" do + body = client.get("/releases/index").read + expect(body).to be(:include?, 'href="#v1.1.0"') + expect(body).to be(:include?, "Improved documentation.") + File.unlink(File.join(@root, "releases.md")) + expect(client.get("/releases/index").read).to be(:include?, "This project does not have a") + end + + it "renders fallback content without a README" do + File.unlink(File.join(@root, "readme.md")) + body = client.get("/index").read + expect(body).to be(:include?, "This project does not have a") + end + + ["Introductory paragraph.", "# *Formatted title*", "", "#", "##"].each do |markdown| + with "README #{markdown.inspect}" do + it "renders a fallback heading" do + write("readme.md", markdown) + response = client.get("/index") + expect(response.status).to be == 200 + expect(response.read).to be(:include?, "

Project

") + end + end + end + + ["#", "##"].each do |heading| + with "empty #{heading.inspect} heading followed by an introduction" do + it "renders fallback titles and preserves the introduction" do + markdown = "#{heading}\n\nIntroduction.\n" + write("readme.md", markdown) + write("guides/empty/readme.md", markdown) + guide = base.guides["empty"] + expect(guide.title).to be == "Empty" + expect(guide.description.to_plaintext).to be == "Introduction.\n" + + {"/index" => "Project", "/guides/empty/index" => "Empty"}.each do |path, title| + response = client.get(path) + body = response.read + expect(response.status).to be == 200 + expect(body).to be(:include?, "

#{title}

") + expect(body).to be(:include?, "

Introduction.

") + end + end + end + end + + ["svg", "png"].each do |extension| + with "#{extension} title image" do + it "renders a logo and page title" do + write("readme.md", "# ![Project Logo](logo.#{extension})\n\nIntroduction.") + body = client.get("/index").read + expect(body).to be(:include?, "Project Logo") + expect(body).to be(:include?, "logo.#{extension}") + end + end + end + + it "renders the exception document" do + expect(client.get("/errors/exception").read).to be(:include?, "something didn't quite work out") + end + + it "renders discussion settings when configured" do + key = "UTOPIA_PROJECT_GISCUS_REPO" + previous = ENV[key] + begin + ENV[key] = "example/project" + expect(client.get("/reference/Example/Client/index").read).to be(:include?, 'data-repo="example/project"') + ensure + previous ? ENV[key] = previous : ENV.delete(key) + end + end +end + +describe "Application configuration" do + include Utopia::Project::SiteContext + + it "serves healthy requests with production exception middleware" do + mock(UTOPIA) do |wrapper| + wrapper.replace(:production?){true} + end + + response = client.get("/index") + expect(response.status).to be == 200 + expect(response.read).to be(:include?, "Example Project") + end + + it "serves documentation with localization enabled" do + @middleware = Utopia::Application.build do |builder| + Utopia::Project.call(builder, @root, locales: ["en", "ja"]) + end + + response = client.get("/index", {"accept-language" => "ja"}) + expect(response.status).to be == 200 + expect(response.read).to be(:include?, "Example Project") + end + + it "lists guides with and without descriptions" do + response = client.get("/guides/index") + expect(response.status).to be == 200 + expect(response.read).to be(:include?, "Source Example") + end +end diff --git a/test/utopia/project/tasks.rb b/test/utopia/project/tasks.rb new file mode 100644 index 0000000..4f9e2b6 --- /dev/null +++ b/test/utopia/project/tasks.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "utopia/project/site" +require "bake/context" +require "stringio" +require "yaml" + +describe "Documentation tasks" do + include Utopia::Project::SiteContext + + let(:context) {Bake::Context.load(@root)} + + it "updates README and agent context deterministically" do + Dir.chdir(@root) do + context["utopia:project:update"].call + first = File.read("readme.md") + index = YAML.load_file("context/index.yaml") + + expect(first).to be(:include?, "https://example.com/project/guides/getting-started/index") + expect(first).to be(:include?, "This example explains how to call the client.") + expect(first).to be(:include?, "v1.1.0") + expect(first).to be(:include?, "#code-examples") + expect(index["files"].map{|entry| entry["path"]}).to be == ["getting-started.md", "empty.md"] + expect(File.read("context/getting-started.md")).to be == File.read("guides/getting-started/readme.md") + context["utopia:project:update"].call + expect(File.read("readme.md")).to be == first + expect(YAML.load_file("context/index.yaml")).to be == index + end + end + + it "generates no context index when there are no guides" do + FileUtils.remove_entry(File.join(@root, "guides")) + expect(context["utopia:project:agent:context:update"].call).to be_nil + expect(File).not.to be(:exist?, File.join(@root, "context/index.yaml")) + end + + it "uses the homepage when no documentation URL is configured" do + write("example.gemspec", 'Gem::Specification.new {|s| s.name = "example"; s.version = "1.0"; s.homepage = "https://example.com/fallback/"}') + Dir.chdir(@root) do + context["utopia:project:readme:update"].call + expect(File.read("readme.md")).to be(:include?, "https://example.com/fallback/guides/getting-started/index") + end + end + + it "creates a project template in an empty directory" do + mock(FileUtils::Verbose) do |wrapper| + wrapper.replace(:cp_r){|source, destination| FileUtils.cp_r(source, destination)} + end + Dir.mktmpdir do |root| + Dir.chdir(root) do + context["utopia:project:create"].call + expect(File).to be(:exist?, "config/serve.rb") + expect(File.read("config/application.rb")).to be(:include?, "Utopia::Project.call") + end + end + end + + it "extracts the first sentence as the project description" do + previous = $stdout + output = StringIO.new + begin + $stdout = output + ["# Example", "#", "##"].each do |heading| + write("readme.md", "#{heading}\n\nFirst sentence. Second sentence.\n") + context["utopia:project:description"].call + end + ensure + $stdout = previous + end + expect(output.string).to be == "First sentence.\n" * 3 + end + + it "passes custom binding options to Falcon" do + recipe = context["utopia:project:serve"] + commands = [] + mock(recipe.instance) do |wrapper| + wrapper.replace(:system){|*command| commands << command; true} + end + + recipe.call(port: 9293, bind: "http://127.0.0.1") + expect(commands.first.first(2)).to be == ["falcon", "serve"] + expect(commands.first.last(4)).to be == ["--bind", "http://127.0.0.1", "--port", "9293"] + end + + it "marks static output for GitHub Pages and builds its search index" do + output = File.join(@root, "export") + generate = context["utopia:static:generate"] + mock(generate.instance) do |wrapper| + wrapper.replace(:generate) do |output_path:, application_path:, public_path:, force:| + expect(output_path).to be == output + expect(force).to be == false + expect(File).to be(:exist?, application_path) + expect(File).to be(:directory?, public_path) + FileUtils.mkdir_p(output_path) + end + end + indexed = nil + mock(context["utopia:project:search:build"].instance) do |wrapper| + wrapper.replace(:build){|output_path:| indexed = output_path} + end + + context["utopia:project:static"].call(output_path: output, force: false) + expect(File).to be(:exist?, File.join(output, ".nojekyll")) + expect(indexed).to be == output + end + + it "builds search for the selected directory and propagates failures" do + recipe = context["utopia:project:search:build"] + commands = [] + mock(recipe.instance) do |wrapper| + wrapper.replace(:system){|*command| commands << command; false} + end + + expect{recipe.call(output_path: "a path with spaces")}.to raise_exception(RuntimeError, message: be =~ /Pagefind index build failed/) + expect(commands.first.last(3)).to be == ["pagefind@1.5.2", "--site", "a path with spaces"] + end + + it "builds Pagefind in order and returns its executable" do + recipe = context["utopia:project:pagefind"] + commands = [] + write("pagefind/target/release/pagefind", "#!/bin/sh\n") + binary = File.join(@root, "pagefind/target/release/pagefind") + File.chmod(0o755, binary) + mock(recipe.instance) do |wrapper| + wrapper.replace(:system) do |*command, chdir:| + commands << [command, chdir] + true + end + end + + expect(recipe.call(source_path: File.join(@root, "pagefind"))).to be == binary + expect(commands.first).to be == [["npm", "ci"], File.join(@root, "pagefind/pagefind_web_js")] + expect(commands.last).to be == [["cargo", "build", "--release", "--features", "extended"], File.join(@root, "pagefind/pagefind")] + end + + it "reports failed Pagefind commands and missing executables" do + recipe = context["utopia:project:pagefind"] + mock(recipe.instance) do |wrapper| + wrapper.replace(:system){false} + end + expect{recipe.call(source_path: @root)}.to raise_exception(RuntimeError, message: be =~ /Pagefind build failed/) + mock(recipe.instance) do |wrapper| + wrapper.replace(:system){true} + end + expect{recipe.call(source_path: @root)}.to raise_exception(RuntimeError, message: be =~ /did not produce an executable/) + end +end