From 979a0a0ae2a25682d0f91d83847edb7a4f7d9d71 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Sun, 23 Aug 2026 13:01:55 -0700 Subject: [PATCH 1/4] Add jobs view integration tests for selection, pinning and classification Extract the API fixture mocks from jobs_view.spec.js into a shared mockJobsApi.js helper, extended with a logged-in mode and capture of POSTs to the note and bug-job-map endpoints. New coverage: - job_selection.spec.js: selectedTaskRun deep links, n/p and arrow-key navigation, escape clearing the selection, and browser back after selecting a job - pinboard_classification.spec.js: pinning via spacebar, the details panel pin button and the push header pin-all button, adding a related bug with the b shortcut, the logged-out save error, and a full logged-in classification asserting the API payloads - jobs_view.spec.js: the u unclassified-failures filter toggle and searchStr deep links --- .../job-view/job_selection.spec.js | 138 ++++++++++++ .../ui/integration/job-view/jobs_view.spec.js | 163 ++++---------- tests/ui/integration/job-view/mockJobsApi.js | 200 ++++++++++++++++++ .../job-view/pinboard_classification.spec.js | 170 +++++++++++++++ 4 files changed, 554 insertions(+), 117 deletions(-) create mode 100644 tests/ui/integration/job-view/job_selection.spec.js create mode 100644 tests/ui/integration/job-view/mockJobsApi.js create mode 100644 tests/ui/integration/job-view/pinboard_classification.spec.js diff --git a/tests/ui/integration/job-view/job_selection.spec.js b/tests/ui/integration/job-view/job_selection.spec.js new file mode 100644 index 00000000000..78e7fde95e6 --- /dev/null +++ b/tests/ui/integration/job-view/job_selection.spec.js @@ -0,0 +1,138 @@ +/** + * Integration tests for job selection in the Jobs view: deep links, + * keyboard navigation between jobs, and clearing the selection. + * + * Job selection is URL-first (the selectedTaskRun param is the source + * of truth), so these tests assert both the visible selection and the + * URL staying in sync — a recurring regression area. + */ + +const { test, expect } = require('@playwright/test'); + +const { + mockJobsViewApi, + jobBySymbol, + taskRunStr, + BUILD_JOB, +} = require('./mockJobsApi'); + +// The two unclassified failures on each push: B (busted) and Meh +// (testfailed). Cpp is testfailed but already classified. +const MEH_JOB = jobBySymbol('Meh'); + +// The selected job's button on the push list. The same fixture job +// list is served for every push, so a selected job lights up its copy +// on each push — assert on the first copy. +const selectedJob = (page) => + page.locator('#push-list .job-btn.selected-job').first(); + +const clickJob = (page, symbol) => + page + .getByTestId('job-btn') + .filter({ hasText: new RegExp(`^${symbol}$`) }) + .first() + .click(); + +test.describe('Job selection', () => { + test('deep link with selectedTaskRun selects the job on load', async ({ + page, + }) => { + await mockJobsViewApi(page); + await page.goto( + `/jobs?repo=autoland&selectedTaskRun=${taskRunStr(BUILD_JOB)}`, + ); + + await expect(selectedJob(page)).toBeVisible(); + + const detailsPanel = page.locator('#details-panel'); + await expect(detailsPanel).toBeVisible(); + await expect(detailsPanel).toContainText(BUILD_JOB.job_type_name); + }); + + test.describe('with the default view loaded', () => { + test.beforeEach(async ({ page }) => { + await mockJobsViewApi(page); + await page.goto('/jobs?repo=autoland'); + await expect(page.getByTestId('push-header').first()).toBeVisible(); + // Keyboard navigation needs the job buttons rendered. + await expect(page.getByTestId('job-btn').first()).toBeVisible(); + }); + + test('"n" and "p" step through unclassified failures and update the URL', async ({ + page, + }) => { + // First "n" selects the first unclassified failure: the busted B job. + await page.keyboard.press('n'); + await expect(selectedJob(page)).toHaveText('B'); + await expect(page).toHaveURL( + new RegExp(`selectedTaskRun=${taskRunStr(BUILD_JOB)}`), + ); + + // Next unclassified failure on the same push is the Meh job + // (Cpp is skipped because it is already classified). + await page.keyboard.press('n'); + await expect(selectedJob(page)).toHaveText('Meh'); + await expect(page).toHaveURL( + new RegExp(`selectedTaskRun=${taskRunStr(MEH_JOB)}`), + ); + + // "p" steps back to the previous unclassified failure. + await page.keyboard.press('p'); + await expect(selectedJob(page)).toHaveText('B'); + await expect(page).toHaveURL( + new RegExp(`selectedTaskRun=${taskRunStr(BUILD_JOB)}`), + ); + }); + + test('arrow keys step through all jobs regardless of status', async ({ + page, + }) => { + await clickJob(page, 'B'); + await expect(selectedJob(page)).toHaveText('B'); + + // Right arrow moves to the next job of any result status — the + // already-classified Cpp job, which "n"/"p" would skip. + await page.keyboard.press('ArrowRight'); + await expect(selectedJob(page)).toHaveText(/^Cpp/); + + await page.keyboard.press('ArrowLeft'); + await expect(selectedJob(page)).toHaveText('B'); + }); + + test('escape clears the selected job and the URL param', async ({ + page, + }) => { + await clickJob(page, 'B'); + await expect(selectedJob(page)).toBeVisible(); + await expect(page).toHaveURL(/selectedTaskRun=/); + + await page.keyboard.press('Escape'); + + await expect( + page.locator('#push-list .job-btn.selected-job'), + ).toHaveCount(0); + await expect(page).not.toHaveURL(/selectedTaskRun=/); + }); + + test('browser back after selecting a job clears the selection', async ({ + page, + }) => { + await clickJob(page, 'B'); + await expect(selectedJob(page)).toBeVisible(); + await expect(page).toHaveURL(/selectedTaskRun=/); + + // Selecting a job can push more than one (identical) history + // entry, so step back until we reach the pre-selection entry. + let backSteps = 0; + do { + await page.goBack(); + backSteps += 1; + } while (/selectedTaskRun=/.test(page.url()) && backSteps < 5); + + await expect(page).not.toHaveURL(/selectedTaskRun=/); + await expect( + page.locator('#push-list .job-btn.selected-job'), + ).toHaveCount(0); + }); + }); +}); diff --git a/tests/ui/integration/job-view/jobs_view.spec.js b/tests/ui/integration/job-view/jobs_view.spec.js index f08d3fc0b65..302a45d1ff2 100644 --- a/tests/ui/integration/job-view/jobs_view.spec.js +++ b/tests/ui/integration/job-view/jobs_view.spec.js @@ -1,129 +1,15 @@ /** * Integration tests for the Jobs view: rendering the push list, - * selecting a job, viewing the details panel, and quick filtering. + * selecting a job, viewing the details panel, and filtering. * * API responses are served from the JSON fixtures in tests/ui/mock/ - * (the same fixtures the Jest unit tests use), so the tests are + * via the shared mocks in mockJobsApi.js, so the tests are * deterministic and independent of any backend. */ -const fs = require('node:fs'); -const path = require('node:path'); - const { test, expect } = require('@playwright/test'); -const MOCK_DIR = path.resolve(__dirname, '../../mock'); -const loadFixture = (file) => - JSON.parse(fs.readFileSync(path.join(MOCK_DIR, file), 'utf8')); - -const repositories = loadFixture('repositories.json'); -const pushList = loadFixture('push_list.json'); -const jobList = loadFixture('job_list/job_1.json'); -const taskDefinition = loadFixture('task_definition.json'); - -// The job list endpoint returns rows of values keyed by job_property_names; -// zip them into objects for the /jobs/{id}/ detail endpoint. -const jobsById = new Map( - jobList.results.map((row) => { - const job = Object.fromEntries( - jobList.job_property_names.map((name, i) => [name, row[i]]), - ); - return [job.id, job]; - }), -); - -// The busted build job on the first push in push_list.json. -const BUILD_JOB = [...jobsById.values()].find( - (job) => job.job_type_symbol === 'B', -); - -const json = (body) => ({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(body), -}); - -async function mockJobsViewApi(page) { - await page.route('**/revision.txt', (route) => - route.fulfill({ status: 200, contentType: 'text/plain', body: 'abc123' }), - ); - await page.route('**/api/repository/', (route) => - route.fulfill(json(repositories)), - ); - await page.route('**/api/user/', (route) => route.fulfill(json([]))); - await page.route('**/api/failureclassification/', (route) => - route.fulfill(json([])), - ); - await page.route('**/api/performance/framework/', (route) => - route.fulfill(json([])), - ); - await page.route('**/api/performance/tag/', (route) => - route.fulfill(json([])), - ); - - // Initial push list; polling and other push queries get empty results. - await page.route('**/api/project/autoland/push/**', (route) => { - const url = new URL(route.request().url()); - if (url.searchParams.get('count') === '10') { - return route.fulfill(json(pushList)); - } - return route.fulfill(json({ results: [] })); - }); - - // Job list per push. - await page.route('**/api/jobs/**', (route) => route.fulfill(json(jobList))); - - // Details panel endpoints for the selected job. - await page.route('**/api/project/autoland/jobs/**', (route) => { - const { pathname } = new URL(route.request().url()); - if ( - pathname.endsWith('/text_log_errors/') || - pathname.endsWith('/bug_suggestions/') - ) { - return route.fulfill(json([])); - } - const match = pathname.match(/\/jobs\/(\d+)\/$/); - const job = match && jobsById.get(Number(match[1])); - if (job) { - return route.fulfill(json(job)); - } - return route.fulfill(json([])); - }); - await page.route('**/api/project/autoland/note/**', (route) => - route.fulfill(json([])), - ); - await page.route('**/api/project/autoland/bug-job-map/**', (route) => - route.fulfill(json([])), - ); - await page.route('**/api/project/autoland/performance/job-data/**', (route) => - route.fulfill(json([])), - ); - await page.route('**/api/project/autoland/job-log-url/**', (route) => - route.fulfill(json([])), - ); - - // External services. - await page.route( - 'https://treestatus.prod.lando.prod.cloudops.mozgcp.net/**', - (route) => - route.fulfill( - json({ result: { status: 'open', reason: '', tree: 'autoland' } }), - ), - ); - await page.route('https://firefox-ci-tc.services.mozilla.com/**', (route) => { - const { pathname } = new URL(route.request().url()); - if (pathname.endsWith('/artifacts')) { - return route.fulfill(json({ artifacts: [] })); - } - if (pathname.includes(`/api/queue/v1/task/${BUILD_JOB.task_id}`)) { - return route.fulfill(json(taskDefinition)); - } - return route.fulfill({ status: 404, body: '' }); - }); - await page.route('https://bugzilla.mozilla.org/rest/bug**', (route) => - route.fulfill(json({ bugs: [] })), - ); -} +const { mockJobsViewApi, BUILD_JOB } = require('./mockJobsApi'); test.describe('Jobs View', () => { test.beforeEach(async ({ page }) => { @@ -173,4 +59,47 @@ test.describe('Jobs View', () => { await expect(buildJobs).toHaveCount(0); await expect(yamlJobs.first()).toBeVisible(); }); + + test('"u" toggles the unclassified-failures filter', async ({ page }) => { + const successJobs = page.getByTestId('job-btn').filter({ hasText: /^D$/ }); + const classifiedJobs = page + .getByTestId('job-btn') + .filter({ hasText: /^Cpp/ }); + const bustedJobs = page.getByTestId('job-btn').filter({ hasText: /^B$/ }); + + await expect(successJobs.first()).toBeVisible(); + await expect(classifiedJobs.first()).toBeVisible(); + + await page.keyboard.press('u'); + + await expect(page).toHaveURL(/classifiedState=unclassified/); + await expect(page).toHaveURL(/resultStatus=testfailed/); + + // Successful and already-classified jobs are filtered out; + // unclassified failures remain. + await expect(successJobs).toHaveCount(0); + await expect(classifiedJobs).toHaveCount(0); + await expect(bustedJobs.first()).toBeVisible(); + + // Toggling again restores the unfiltered view. + await page.keyboard.press('u'); + + await expect(page).not.toHaveURL(/classifiedState/); + await expect(successJobs.first()).toBeVisible(); + await expect(classifiedJobs.first()).toBeVisible(); + }); +}); + +test.describe('Jobs View deep links', () => { + test('loading a URL with searchStr applies the filter', async ({ page }) => { + await mockJobsViewApi(page); + await page.goto('/jobs?repo=autoland&searchStr=yaml'); + + await expect( + page.getByTestId('job-btn').filter({ hasText: 'yaml' }).first(), + ).toBeVisible(); + await expect( + page.getByTestId('job-btn').filter({ hasText: /^B$/ }), + ).toHaveCount(0); + }); }); diff --git a/tests/ui/integration/job-view/mockJobsApi.js b/tests/ui/integration/job-view/mockJobsApi.js new file mode 100644 index 00000000000..3f9302a9317 --- /dev/null +++ b/tests/ui/integration/job-view/mockJobsApi.js @@ -0,0 +1,200 @@ +/** + * Shared API mocks for the Jobs view integration tests. + * + * API responses are served from the JSON fixtures in tests/ui/mock/ + * (the same fixtures the Jest unit tests use), so the tests are + * deterministic and independent of any backend. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const MOCK_DIR = path.resolve(__dirname, '../../mock'); +const loadFixture = (file) => + JSON.parse(fs.readFileSync(path.join(MOCK_DIR, file), 'utf8')); + +const repositories = loadFixture('repositories.json'); +const pushList = loadFixture('push_list.json'); +const jobList = loadFixture('job_list/job_1.json'); +const taskDefinition = loadFixture('task_definition.json'); + +// The job list endpoint returns rows of values keyed by job_property_names; +// zip them into objects for the /jobs/{id}/ detail endpoint. +const jobsById = new Map( + jobList.results.map((row) => { + const job = Object.fromEntries( + jobList.job_property_names.map((name, i) => [name, row[i]]), + ); + return [job.id, job]; + }), +); + +const jobBySymbol = (symbol) => + [...jobsById.values()].find((job) => job.job_type_symbol === symbol); + +// The busted build job on the first push in push_list.json. +const BUILD_JOB = jobBySymbol('B'); + +// The selectedTaskRun URL parameter value for a job. +const taskRunStr = (job) => `${job.task_id}.${job.retry_id}`; + +const FAILURE_CLASSIFICATIONS = [ + { id: 1, name: 'not classified' }, + { id: 2, name: 'fixed by commit' }, + { id: 3, name: 'expected fail' }, + { id: 4, name: 'intermittent' }, + { id: 5, name: 'infra' }, + { id: 6, name: 'new failure not classified' }, + { id: 8, name: 'intermittent needs bugid' }, +]; + +const SHERIFF_USER = { + id: 1, + username: 'mozilla-ldap/sheriff@mozilla.com', + email: 'sheriff@mozilla.com', + is_staff: true, + is_superuser: false, +}; + +const json = (body) => ({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), +}); + +/** + * Seed the browser session so the app treats the user as logged in. + * The Login component only trusts the /api/user/ response when a + * userSession entry exists in localStorage; pair this with + * `mockJobsViewApi(page, { user: [SHERIFF_USER] })`. + * Must be called before page.goto(). + */ +async function seedLoggedInSession(page) { + await page.addInitScript(() => { + localStorage.setItem( + 'userSession', + JSON.stringify({ + fullName: 'Sheriff Tester', + renewAfter: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + ); + }); +} + +/** + * Route all API calls the Jobs view makes to fixture data. + * + * Returns a `captured` object that accumulates the payloads of POSTs + * to the classification endpoints, so tests can assert what would + * have been written to the backend. + */ +async function mockJobsViewApi(page, { user = [] } = {}) { + const captured = { notes: [], bugJobMaps: [] }; + + await page.route('**/revision.txt', (route) => + route.fulfill({ status: 200, contentType: 'text/plain', body: 'abc123' }), + ); + await page.route('**/api/repository/', (route) => + route.fulfill(json(repositories)), + ); + await page.route('**/api/user/', (route) => route.fulfill(json(user))); + await page.route('**/api/failureclassification/', (route) => + route.fulfill(json(FAILURE_CLASSIFICATIONS)), + ); + await page.route('**/api/performance/framework/', (route) => + route.fulfill(json([])), + ); + await page.route('**/api/performance/tag/', (route) => + route.fulfill(json([])), + ); + + // Initial push list; polling and other push queries get empty results. + await page.route('**/api/project/autoland/push/**', (route) => { + const url = new URL(route.request().url()); + if (url.searchParams.get('count') === '10') { + return route.fulfill(json(pushList)); + } + return route.fulfill(json({ results: [] })); + }); + + // Job list per push. + await page.route('**/api/jobs/**', (route) => route.fulfill(json(jobList))); + + // Details panel endpoints for the selected job. + await page.route('**/api/project/autoland/jobs/**', (route) => { + const { pathname } = new URL(route.request().url()); + if ( + pathname.endsWith('/text_log_errors/') || + pathname.endsWith('/bug_suggestions/') + ) { + return route.fulfill(json([])); + } + const match = pathname.match(/\/jobs\/(\d+)\/$/); + const job = match && jobsById.get(Number(match[1])); + if (job) { + return route.fulfill(json(job)); + } + return route.fulfill(json([])); + }); + + // Classification endpoints: capture writes, serve empty reads. + await page.route('**/api/project/autoland/note/**', (route) => { + if (route.request().method() === 'POST') { + const payload = route.request().postDataJSON(); + captured.notes.push(payload); + return route.fulfill(json({ ...payload, id: captured.notes.length })); + } + return route.fulfill(json([])); + }); + await page.route('**/api/project/autoland/bug-job-map/**', (route) => { + if (route.request().method() === 'POST') { + const payload = route.request().postDataJSON(); + captured.bugJobMaps.push(payload); + return route.fulfill(json(payload)); + } + return route.fulfill(json([])); + }); + + await page.route('**/api/project/autoland/performance/job-data/**', (route) => + route.fulfill(json([])), + ); + await page.route('**/api/project/autoland/job-log-url/**', (route) => + route.fulfill(json([])), + ); + + // External services. + await page.route( + 'https://treestatus.prod.lando.prod.cloudops.mozgcp.net/**', + (route) => + route.fulfill( + json({ result: { status: 'open', reason: '', tree: 'autoland' } }), + ), + ); + await page.route('https://firefox-ci-tc.services.mozilla.com/**', (route) => { + const { pathname } = new URL(route.request().url()); + if (pathname.endsWith('/artifacts')) { + return route.fulfill(json({ artifacts: [] })); + } + if (pathname.includes('/api/queue/v1/task/')) { + return route.fulfill(json(taskDefinition)); + } + return route.fulfill({ status: 404, body: '' }); + }); + await page.route('https://bugzilla.mozilla.org/rest/bug**', (route) => + route.fulfill(json({ bugs: [] })), + ); + + return captured; +} + +module.exports = { + mockJobsViewApi, + seedLoggedInSession, + jobsById, + jobBySymbol, + taskRunStr, + BUILD_JOB, + SHERIFF_USER, + FAILURE_CLASSIFICATIONS, + pushList, +}; diff --git a/tests/ui/integration/job-view/pinboard_classification.spec.js b/tests/ui/integration/job-view/pinboard_classification.spec.js new file mode 100644 index 00000000000..a65f87958ac --- /dev/null +++ b/tests/ui/integration/job-view/pinboard_classification.spec.js @@ -0,0 +1,170 @@ +/** + * Integration tests for the pinboard and the classification flow: + * pinning jobs (keyboard, details panel, push header), attaching + * related bugs, and saving classifications both logged out and + * logged in. + */ + +const { test, expect } = require('@playwright/test'); + +const { + mockJobsViewApi, + seedLoggedInSession, + BUILD_JOB, + SHERIFF_USER, +} = require('./mockJobsApi'); + +// The same fixture job list is served for every push, so a selected +// job lights up its copy on each push — assert on the first copy. +const selectBuildJob = async (page) => { + await page.getByTestId('job-btn').filter({ hasText: /^B$/ }).first().click(); + await expect( + page.locator('#push-list .job-btn.selected-job').first(), + ).toBeVisible(); +}; + +test.describe('Pinboard', () => { + test.beforeEach(async ({ page }) => { + await mockJobsViewApi(page); + await page.goto('/jobs?repo=autoland'); + await expect(page.getByTestId('push-header').first()).toBeVisible(); + }); + + test('spacebar pins the selected job', async ({ page }) => { + await selectBuildJob(page); + await page.keyboard.press(' '); + + const pinboard = page.locator('#pinboard-panel'); + await expect(pinboard).toBeVisible(); + await expect(pinboard.locator('.pinned-job')).toHaveText('B'); + + // Un-pinning the job leaves the pinboard empty again. + await pinboard.getByTitle('un-pin this job').click(); + await expect(pinboard.locator('.pinned-job')).toHaveCount(0); + await expect(pinboard).toContainText( + 'press spacebar to pin a selected job', + ); + }); + + test('the details panel pin button pins the selected job', async ({ + page, + }) => { + await selectBuildJob(page); + await page.locator('#pin-job-btn').click(); + + const pinboard = page.locator('#pinboard-panel'); + await expect(pinboard).toBeVisible(); + await expect(pinboard.locator('.pinned-job')).toHaveText('B'); + }); + + test('the push header pin-all button pins every shown job on the push', async ({ + page, + }) => { + await page.locator('.pin-all-jobs-btn').first().click(); + + const pinboard = page.locator('#pinboard-panel'); + await expect(pinboard).toBeVisible(); + // The fixture push has five jobs: D, B, yaml, Cpp and Meh. + await expect(pinboard.locator('.pinned-job')).toHaveCount(5); + }); + + test('"b" pins the job and adds a related bug to the pinboard', async ({ + page, + }) => { + await selectBuildJob(page); + await page.keyboard.press('b'); + + const bugInput = page.locator('#related-bug-input'); + await expect(bugInput).toBeVisible(); + await expect(bugInput).toBeFocused(); + + await bugInput.fill('123456'); + await bugInput.press('Enter'); + + await expect(page.getByTestId('pinboard-bug-123456')).toBeVisible(); + await expect( + page.locator('#pinboard-panel .pinned-job'), + ).toHaveText('B'); + }); +}); + +test.describe('Classification', () => { + test('saving while logged out shows an error notification', async ({ + page, + }) => { + await mockJobsViewApi(page); + await page.goto('/jobs?repo=autoland'); + await selectBuildJob(page); + + await page.keyboard.press(' '); + await expect( + page.locator('#pinboard-panel .pinned-job'), + ).toHaveText('B'); + + // The save button is pointer-inert while it can't save, so use the + // keyboard shortcut, which is also how sheriffs normally save. + await page.keyboard.press('Control+Enter'); + + await expect(page.locator('#notification-box')).toContainText( + 'Must be logged in to save job classifications', + ); + }); + + test('a logged-in user can classify a pinned job with a bug', async ({ + page, + }) => { + await seedLoggedInSession(page); + const captured = await mockJobsViewApi(page, { user: [SHERIFF_USER] }); + await page.goto('/jobs?repo=autoland'); + + // Wait for the app to acknowledge the logged-in user. + await expect(page.locator('#th-global-navbar')).toContainText( + 'Sheriff Tester', + ); + + await selectBuildJob(page); + await page.keyboard.press(' '); + const pinboard = page.locator('#pinboard-panel'); + await expect(pinboard.locator('.pinned-job')).toHaveText('B'); + + // Attach a bug; the default classification type is "intermittent", + // which requires a bug or a comment on non-try repos. + await page.locator('#add-related-bug-button').click(); + const bugInput = page.locator('#related-bug-input'); + await bugInput.fill('123456'); + await bugInput.press('Enter'); + await expect(page.getByTestId('pinboard-bug-123456')).toBeVisible(); + + await expect( + pinboard.locator('#pinboard-classification-select'), + ).toHaveValue('4'); + + await pinboard.locator('.save-btn').click(); + + // The classification and the bug association are written to the API... + await expect + .poll(() => captured.notes.length, { message: 'note POST sent' }) + .toBe(1); + expect(captured.notes[0]).toMatchObject({ + job_id: BUILD_JOB.id, + failure_classification_id: 4, + }); + + await expect + .poll(() => captured.bugJobMaps.length, { + message: 'bug-job-map POST sent', + }) + .toBe(1); + expect(captured.bugJobMaps[0]).toMatchObject({ + job_id: BUILD_JOB.id, + bug_id: 123456, + type: 'annotation', + }); + + // ...and the pinboard is cleared after a successful save. + await expect(pinboard.locator('.pinned-job')).toHaveCount(0); + await expect(pinboard).toContainText( + 'press spacebar to pin a selected job', + ); + }); +}); From 1336a5315401a21143a4b875e7d597f293901486 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Sun, 23 Aug 2026 13:08:18 -0700 Subject: [PATCH 2/4] Add failure summary tab integration tests Serve the bug_suggestions.json fixture through the shared mock helper (new bugSuggestions option) and cover the wiring from a selected failed job through the failure summary tab to the pinboard: - the failure summary is the default tab for a failed job and renders the failure line with its suggested bugs - pinning a suggested bug pins the job with the bug attached - a logged-in user can classify from a suggested bug, asserting the note and bug-job-map API payloads --- .../job-view/failure_summary.spec.js | 123 ++++++++++++++++++ tests/ui/integration/job-view/mockJobsApi.js | 12 +- 2 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/ui/integration/job-view/failure_summary.spec.js diff --git a/tests/ui/integration/job-view/failure_summary.spec.js b/tests/ui/integration/job-view/failure_summary.spec.js new file mode 100644 index 00000000000..80684d34f13 --- /dev/null +++ b/tests/ui/integration/job-view/failure_summary.spec.js @@ -0,0 +1,123 @@ +/** + * Integration tests for the Failure Summary tab: bug suggestions + * rendered for a failed job, and classifying a job from a suggested + * bug through the pinboard. + * + * The bug suggestions themselves come from the bug_suggestions.json + * fixture; generating them is backend logic covered by pytest + * (tests/model/test_error_summary.py). These tests cover the wiring: + * selected job -> failure summary tab -> pinboard -> save. + */ + +const { test, expect } = require('@playwright/test'); + +const { + mockJobsViewApi, + seedLoggedInSession, + BUILD_JOB, + SHERIFF_USER, + BUG_SUGGESTIONS, +} = require('./mockJobsApi'); + +// The open, recent bug on the first suggestion in bug_suggestions.json. +const SUGGESTED_BUG = BUG_SUGGESTIONS[0].bugs.open_recent[0]; + +const selectBuildJob = async (page) => { + await page.getByTestId('job-btn').filter({ hasText: /^B$/ }).first().click(); + await expect( + page.locator('#push-list .job-btn.selected-job').first(), + ).toBeVisible(); +}; + +// The pin button on the suggested bug's row in the failure summary. +const suggestedBugPinButton = (page, bugId) => + page + .getByTestId('bug-list-item') + .filter({ hasText: `bug ${bugId}` }) + .first() + .getByTitle('Add to list of bugs to associate with all pinned jobs'); + +test.describe('Failure summary tab', () => { + test('shows bug suggestions for a failed job', async ({ page }) => { + await mockJobsViewApi(page, { bugSuggestions: BUG_SUGGESTIONS }); + await page.goto('/jobs?repo=autoland'); + await selectBuildJob(page); + + // The failure summary is the default tab for a failed job. + await expect( + page.getByRole('tab', { name: 'Failure Summary', selected: true }), + ).toBeVisible(); + + // The failure line and its suggested bug are rendered. + const detailsPanel = page.locator('#details-panel'); + await expect(detailsPanel).toContainText(BUG_SUGGESTIONS[0].search); + await expect( + detailsPanel.getByRole('link', { + name: new RegExp(`bug ${SUGGESTED_BUG.id}`), + }), + ).toBeVisible(); + }); + + test('pinning a suggested bug pins the job with the bug attached', async ({ + page, + }) => { + await mockJobsViewApi(page, { bugSuggestions: BUG_SUGGESTIONS }); + await page.goto('/jobs?repo=autoland'); + await selectBuildJob(page); + + await suggestedBugPinButton(page, SUGGESTED_BUG.id).click(); + + const pinboard = page.locator('#pinboard-panel'); + await expect(pinboard).toBeVisible(); + await expect(pinboard.locator('.pinned-job')).toHaveText('B'); + await expect( + page.getByTestId(`pinboard-bug-${SUGGESTED_BUG.id}`), + ).toBeVisible(); + }); + + test('a logged-in user can classify from a suggested bug', async ({ + page, + }) => { + await seedLoggedInSession(page); + const captured = await mockJobsViewApi(page, { + user: [SHERIFF_USER], + bugSuggestions: BUG_SUGGESTIONS, + }); + await page.goto('/jobs?repo=autoland'); + await expect(page.locator('#th-global-navbar')).toContainText( + 'Sheriff Tester', + ); + + await selectBuildJob(page); + await suggestedBugPinButton(page, SUGGESTED_BUG.id).click(); + + const pinboard = page.locator('#pinboard-panel'); + await expect( + page.getByTestId(`pinboard-bug-${SUGGESTED_BUG.id}`), + ).toBeVisible(); + + await pinboard.locator('.save-btn').click(); + + await expect + .poll(() => captured.notes.length, { message: 'note POST sent' }) + .toBe(1); + expect(captured.notes[0]).toMatchObject({ + job_id: BUILD_JOB.id, + failure_classification_id: 4, + }); + + await expect + .poll(() => captured.bugJobMaps.length, { + message: 'bug-job-map POST sent', + }) + .toBe(1); + expect(captured.bugJobMaps[0]).toMatchObject({ + job_id: BUILD_JOB.id, + bug_id: SUGGESTED_BUG.id, + type: 'annotation', + }); + + // The pinboard is cleared after a successful save. + await expect(pinboard.locator('.pinned-job')).toHaveCount(0); + }); +}); diff --git a/tests/ui/integration/job-view/mockJobsApi.js b/tests/ui/integration/job-view/mockJobsApi.js index 3f9302a9317..aa6a674b2d3 100644 --- a/tests/ui/integration/job-view/mockJobsApi.js +++ b/tests/ui/integration/job-view/mockJobsApi.js @@ -17,6 +17,7 @@ const repositories = loadFixture('repositories.json'); const pushList = loadFixture('push_list.json'); const jobList = loadFixture('job_list/job_1.json'); const taskDefinition = loadFixture('task_definition.json'); +const BUG_SUGGESTIONS = loadFixture('bug_suggestions.json'); // The job list endpoint returns rows of values keyed by job_property_names; // zip them into objects for the /jobs/{id}/ detail endpoint. @@ -88,7 +89,7 @@ async function seedLoggedInSession(page) { * to the classification endpoints, so tests can assert what would * have been written to the backend. */ -async function mockJobsViewApi(page, { user = [] } = {}) { +async function mockJobsViewApi(page, { user = [], bugSuggestions = [] } = {}) { const captured = { notes: [], bugJobMaps: [] }; await page.route('**/revision.txt', (route) => @@ -123,10 +124,10 @@ async function mockJobsViewApi(page, { user = [] } = {}) { // Details panel endpoints for the selected job. await page.route('**/api/project/autoland/jobs/**', (route) => { const { pathname } = new URL(route.request().url()); - if ( - pathname.endsWith('/text_log_errors/') || - pathname.endsWith('/bug_suggestions/') - ) { + if (pathname.endsWith('/bug_suggestions/')) { + return route.fulfill(json(bugSuggestions)); + } + if (pathname.endsWith('/text_log_errors/')) { return route.fulfill(json([])); } const match = pathname.match(/\/jobs\/(\d+)\/$/); @@ -196,5 +197,6 @@ module.exports = { BUILD_JOB, SHERIFF_USER, FAILURE_CLASSIFICATIONS, + BUG_SUGGESTIONS, pushList, }; From 47bce4b60732a01e29b20225bbc2dbb2f95e5242 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Tue, 1 Sep 2026 10:21:33 -0700 Subject: [PATCH 3/4] Fix job button not re-rendering after saving a classification The class-to-functional conversion replaced JobButton's custom shouldComponentUpdate with a plain memo(). Jobs are mutated in place when a classification is saved, so the memo comparison saw the same job reference and skipped the re-render; the classified star only appeared after the next jobs poll instead of immediately. Restore the old design by passing failure_classification_id and resultStatus as scalar props from JobGroup/JobsAndGroups: prop values are snapshotted at parent render time, so memo() sees the change even though the job object identity is unchanged. --- tests/ui/job-view/pushes/JobButton.test.jsx | 46 +++++++++++++++++++++ ui/job-view/pushes/JobButton.jsx | 33 +++++++++------ ui/job-view/pushes/JobGroup.jsx | 2 + ui/job-view/pushes/JobsAndGroups.jsx | 2 + 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/tests/ui/job-view/pushes/JobButton.test.jsx b/tests/ui/job-view/pushes/JobButton.test.jsx index 9e405add984..e9002fe4b1f 100644 --- a/tests/ui/job-view/pushes/JobButton.test.jsx +++ b/tests/ui/job-view/pushes/JobButton.test.jsx @@ -55,6 +55,8 @@ describe('JobButton', () => { render( { const { container } = render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { render( { { { { render( { render( { @@ -63,17 +68,17 @@ const JobButtonComponent = forwardRef(function JobButtonComponent( const runnable = state === 'runnable'; const { status, isClassified } = getBtnClass( - jobResultStatus, - jobFailureClassificationId, + resultStatus, + failureClassificationId, ); let classifiedIcon = null; if ( - jobFailureClassificationId > 1 && - ![6, 8].includes(jobFailureClassificationId) + failureClassificationId > 1 && + ![6, 8].includes(failureClassificationId) ) { classifiedIcon = - jobFailureClassificationId === 7 ? faStarRegular : faStarSolid; + failureClassificationId === 7 ? faStarRegular : faStarSolid; } const classes = ['btn', 'filter-shown']; @@ -131,6 +136,8 @@ JobButtonComponent.propTypes = { visible: PropTypes.bool.isRequired, filterPlatformCb: PropTypes.func.isRequired, intermittent: PropTypes.bool, + failureClassificationId: PropTypes.number.isRequired, + resultStatus: PropTypes.string.isRequired, }; export default memo(JobButtonComponent); diff --git a/ui/job-view/pushes/JobGroup.jsx b/ui/job-view/pushes/JobGroup.jsx index fd32e66ba74..d6710696458 100644 --- a/ui/job-view/pushes/JobGroup.jsx +++ b/ui/job-view/pushes/JobGroup.jsx @@ -179,6 +179,8 @@ export function JobGroupComponent({ job={job} filterModel={filterModel} visible={job.visible} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterPlatformCb={filterPlatformCb} intermittent={isIntermittent(job)} key={job.id} diff --git a/ui/job-view/pushes/JobsAndGroups.jsx b/ui/job-view/pushes/JobsAndGroups.jsx index 5522cd0d280..63e9c5d24b4 100644 --- a/ui/job-view/pushes/JobsAndGroups.jsx +++ b/ui/job-view/pushes/JobsAndGroups.jsx @@ -180,6 +180,8 @@ export default function JobsAndGroups({ job={job} filterModel={filterModel} visible={job.visible} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterPlatformCb={filterPlatformCb} intermittent={isIntermittent(job)} key={job.id} From 0c0815571f7cf940c60b1ab46a853740f70a7050 Mon Sep 17 00:00:00 2001 From: Cameron Dawson Date: Tue, 1 Sep 2026 10:21:33 -0700 Subject: [PATCH 4/4] Assert the classified job updates in the view after pinboard save Extends the two logged-in classification tests to verify the saved classification is reflected in the push list: the job button gains the star icon and data-classified marker, and (after deselecting) the 'u' unclassified-failures filter no longer shows the job. Notes: - The star svg's adds 'classified' to the button text, so a /^B$/ hasText filter would miss the classified button; match on data-classified instead. - Keyboard shortcuts are ignored while an input has focus and Firefox does not move focus on button clicks, so blur the related-bug input before pressing Escape. --- .../job-view/failure_summary.spec.js | 11 ++++++++ .../job-view/pinboard_classification.spec.js | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/tests/ui/integration/job-view/failure_summary.spec.js b/tests/ui/integration/job-view/failure_summary.spec.js index 80684d34f13..bfa805e74d9 100644 --- a/tests/ui/integration/job-view/failure_summary.spec.js +++ b/tests/ui/integration/job-view/failure_summary.spec.js @@ -119,5 +119,16 @@ test.describe('Failure summary tab', () => { // The pinboard is cleared after a successful save. await expect(pinboard.locator('.pinned-job')).toHaveCount(0); + + // The job now renders as classified in the push list: its button + // on its own push gains the star icon and the classified marker. + // (The star svg's <title> adds "classified" to the button text, + // so a /^B$/ text filter would miss the classified button.) + const classifiedB = page + .getByTestId(`push-${BUILD_JOB.push_id}`) + .locator('[data-testid="job-btn"][data-classified="true"]') + .filter({ hasText: /^B/ }); + await expect(classifiedB).toHaveCount(1); + await expect(classifiedB.locator('.classified-icon')).toBeVisible(); }); }); diff --git a/tests/ui/integration/job-view/pinboard_classification.spec.js b/tests/ui/integration/job-view/pinboard_classification.spec.js index a65f87958ac..b972001a6b0 100644 --- a/tests/ui/integration/job-view/pinboard_classification.spec.js +++ b/tests/ui/integration/job-view/pinboard_classification.spec.js @@ -166,5 +166,31 @@ test.describe('Classification', () => { await expect(pinboard).toContainText( 'press spacebar to pin a selected job', ); + + // The job now renders as classified in the push list: its button + // on its own push gains the star icon and the classified marker. + // (The star svg's <title> adds "classified" to the button text, + // so a /^B$/ text filter would miss the classified button.) + const jobPush = page.getByTestId(`push-${BUILD_JOB.push_id}`); + const classifiedB = jobPush + .locator('[data-testid="job-btn"][data-classified="true"]') + .filter({ hasText: /^B/ }); + await expect(classifiedB).toHaveCount(1); + await expect(classifiedB.locator('.classified-icon')).toBeVisible(); + + // Deselect first (a selected job stays visible regardless of + // filters), then filter to unclassified failures: the newly + // classified job is no longer shown on its push. + // Blur the related-bug input first: keyboard shortcuts are + // ignored while an input has focus, and Firefox does not move + // focus on button clicks. + await page.evaluate(() => document.activeElement?.blur()); + await page.keyboard.press('Escape'); + await expect(page).not.toHaveURL(/selectedTaskRun=/); + await page.keyboard.press('u'); + await expect(page).toHaveURL(/classifiedState=unclassified/); + await expect( + jobPush.locator('[data-testid="job-btn"]').filter({ hasText: /^B/ }), + ).toHaveCount(0); }); });