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
8 changes: 6 additions & 2 deletions src/cli/commands/a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,15 @@ export async function runA11yScan(scanOptions: A11yScanOptions): Promise<A11ySca
}

// Paginate through posts.
// Keep per_page fixed so the server-side offset (page-1)*per_page stays
// stable across every iteration. A shrinking per_page would move the window
// backwards into already-scanned records on the final page.
const PER_PAGE = 20;
let page = 1;
let limitReached = false;
while (postsChecked < limit) {
const perPage = Math.min(20, limit - postsChecked);
const params = new URLSearchParams({
per_page: String(perPage),
per_page: String(PER_PAGE),
page: String(page),
status,
_fields: 'id,title,content',
Expand All @@ -141,6 +144,7 @@ export async function runA11yScan(scanOptions: A11yScanOptions): Promise<A11ySca
if (posts.length === 0) break;

for (const post of posts) {
if (postsChecked >= limit) break; // cap analysis to the remaining budget
postsChecked++;
const title = post.title.rendered.replace(/<[^>]*>/g, '');
analyzePost(post.id, title, post.content.rendered, findings);
Expand Down
70 changes: 70 additions & 0 deletions test/unit/a11y.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,74 @@ describe('runA11yScan', () => {
expect(result.errors[0].postType).toBe('posts');
expect(result.complete).toBe(false);
});

test('non-multiple-of-20 limit: every post analyzed exactly once, none duplicated or skipped', async () => {
// Regression for OSS-1357: per_page was shrinking on the final page, which moved the
// server-side offset backwards. Posts 21-30 were checked twice; posts 41-50 never checked.
const TOTAL_POSTS = 100;

const mockPaginatedFetch = (contentFor: (id: number) => string) =>
(async (url: string | URL | Request) => {
const u = new URL(url.toString());
if (!u.pathname.includes('/posts')) throw new Error(`unexpected url: ${u}`);

const page = Number.parseInt(u.searchParams.get('page') ?? '1', 10);
const perPage = Number.parseInt(u.searchParams.get('per_page') ?? '20', 10);

const start = (page - 1) * perPage; // 0-based
const slice = Array.from({ length: perPage }, (_, i) => start + i + 1) // 1-based IDs
.filter((id) => id <= TOTAL_POSTS)
.map((id) => ({
id,
title: { rendered: `Post ${id}` },
content: { rendered: contentFor(id) },
}));

const totalPages = Math.ceil(TOTAL_POSTS / perPage);
return jsonResponse(slice, { headers: { 'X-WP-TotalPages': String(totalPages) } });
}) as typeof fetch;

// First pass: content has no a11y issues, so a purely count-based assertion suffices here.
globalThis.fetch = mockPaginatedFetch((id) => `<p>content-${id}</p>`);

const result = await runA11yScan({
baseUrl: BASE_URL,
auth: AUTH,
types: ['posts'],
status: 'publish',
limit: 50,
});

expect(result.postsChecked).toBe(50);
expect(result.errors).toEqual([]);
expect(result.findings).toEqual([]);

// Second pass: every post has a missing-alt image, so finding.postId tells us exactly
// which IDs were analyzed — verifying no duplicates and no gaps.
globalThis.fetch = mockPaginatedFetch((id) => `<img src="x.jpg"><p>content-${id}</p>`);

const result2 = await runA11yScan({
baseUrl: BASE_URL,
auth: AUTH,
types: ['posts'],
status: 'publish',
limit: 50,
});

expect(result2.postsChecked).toBe(50);

const ids = result2.findings.map((f) => f.postId);
const uniqueIds = new Set(ids);

// Exactly 50 unique IDs analyzed (no duplicates).
expect(uniqueIds.size).toBe(50);

// IDs are exactly 1–50 (no gaps, none skipped, none from 51+).
for (let i = 1; i <= 50; i++) {
expect(uniqueIds.has(i)).toBe(true);
}
for (let i = 51; i <= 100; i++) {
expect(uniqueIds.has(i)).toBe(false);
}
});
});
Loading