Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ Build before running `npm test`; Playwright starts and stops the local preview a
`npm run test:starter` rebuilds the independent example and runs its generic check. Build the default edition again before deploying the default site. Do not run builds for different editions concurrently in one checkout: they share generated output paths.

Tests use fresh browser contexts. Screenshots and failure traces stay in ignored local directories. Automated accessibility checks cover detected rules, not every aspect of accessibility; manually review reading comfort, keyboard order and a phone-sized view when changing layout or motion.

## Search-fragment security regression — 5 October 2026

`tests/search-fragment.spec.ts` covers malformed percent escapes on direct and history navigation, safe first-section fallback, and valid encoded Unicode search. Search terms are decoded once per render through a guarded helper. Existing chapter, passage and ordinary search links retain their behavior.

Verification for this candidate: 11 compiler/unit tests and the production build pass; all 14 pre-existing browser cases pass, and all four new search-fragment cases pass with installed Chrome. The first new-test run exposed an ambiguous locator (three Original text buttons); the test now selects the existing mode switch. npm's dated install audit reports zero vulnerabilities. This is local candidate evidence, not deployment or cloud-finding closure.
10 changes: 9 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ function route() {try{return decodeURI(location.pathname).replace(/\/$/,'')||'/'
function label(e:Entry) {const work=workFor(e.work);return work.numbering==='none'?work.unit:`${work.unit} ${numberFor(e.work,e.number)}`;}
function readingTop(){const bar=document.querySelector('.reader-toolbar');return bar?Math.max(0,bar.getBoundingClientRect().bottom)+16:90;}

// URL fragments are external input, including when restored from history.
function searchFragment(sectionId:string):string|null {
if(!sectionId.startsWith('find-'))return null;
try{return decodeURIComponent(sectionId.slice(5)).toLowerCase() || null;}
catch{return null;}
}


function App(){
const [path,setPath]=useState(route);
Expand Down Expand Up @@ -133,7 +140,8 @@ function App(){
setPreferences(p=>({...p,...patch}));
if(anchor&&offset!==undefined)requestAnimationFrame(()=>{document.fonts.ready.then(()=>requestAnimationFrame(()=>window.scrollBy({top:anchor.getBoundingClientRect().top-offset,behavior:'instant'})));});
}
const selectedSection=chapter?.sections.find(s=>s.id===sectionId)||chapter?.sections.find(s=>sectionId.startsWith('find-')&&s.text.toLowerCase().includes(decodeURIComponent(sectionId.slice(5)).toLowerCase()))||chapter?.sections.find(s=>{
const searchTerm=searchFragment(sectionId);
const selectedSection=chapter?.sections.find(s=>s.id===sectionId)||chapter?.sections.find(s=>searchTerm!==null&&s.text.toLowerCase().includes(searchTerm))||chapter?.sections.find(s=>{
const match=sectionId.match(/-q(\d+)$/);if(!match)return false;
const before=chapter.sections.slice(0,chapter.sections.indexOf(s)).reduce((n,x)=>n+(x.html.match(/<blockquote>/g)?.length??0),0);
const count=s.html.match(/<blockquote>/g)?.length??0;
Expand Down
30 changes: 30 additions & 0 deletions tests/search-fragment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test, expect } from '@playwright/test';

for (const hash of ['find-%', 'find-%GG', 'find-%E0%A4%A']) {
test(`malformed fragment ${hash} falls back on direct and history navigation`, async ({page, request}) => {
const library = await (await request.get('/content/library.json')).json();
const chapter = await (await request.get(`/content/${library[0].id}.json`)).json();
const errors:string[]=[];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`/read/${chapter.id}#${hash}`);
await expect(page.locator('#chapter-title')).toBeVisible();
await expect(page.locator('.prose')).toContainText(chapter.sections[0].text.slice(0,40));
await page.locator('.mode-switch').getByRole('button',{name:'Original text',exact:true}).click();
await page.goBack();
await expect(page.locator('.prose')).toContainText(chapter.sections[0].text.slice(0,40));
await page.goForward();
await expect(page.locator('#chapter-title')).toBeVisible();
expect(errors).toEqual([]);
});
}

test('valid encoded Unicode search still selects the matching section', async ({page, request}) => {
const library = await (await request.get('/content/library.json')).json();
const chapter = await (await request.get(`/content/${library[0].id}.json`)).json();
// Use a synthetic section so the UTF-8 regression does not depend on edition prose.
chapter.sections[1].text = 'A café beside the sea';
chapter.sections[1].html = '<p>A café beside the sea</p>';
await page.route(`**/content/${chapter.id}.json`, route => route.fulfill({json:chapter}));
await page.goto(`/read/${chapter.id}#find-${encodeURIComponent('café')}`);
await expect(page.locator('.prose')).toContainText('A café beside the sea');
});
Loading