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..bfa805e74d9 --- /dev/null +++ b/tests/ui/integration/job-view/failure_summary.spec.js @@ -0,0 +1,134 @@ +/** + * 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); + + // 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 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/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..aa6a674b2d3 --- /dev/null +++ b/tests/ui/integration/job-view/mockJobsApi.js @@ -0,0 +1,202 @@ +/** + * 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'); +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. +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 = [], bugSuggestions = [] } = {}) { + 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('/bug_suggestions/')) { + return route.fulfill(json(bugSuggestions)); + } + if (pathname.endsWith('/text_log_errors/')) { + 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, + BUG_SUGGESTIONS, + 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..b972001a6b0 --- /dev/null +++ b/tests/ui/integration/job-view/pinboard_classification.spec.js @@ -0,0 +1,196 @@ +/** + * 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', + ); + + // 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); + }); +}); 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( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -72,6 +74,8 @@ describe('JobButton', () => { const { container } = render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible={false} filterPlatformCb={filterPlatformCb} @@ -89,6 +93,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -108,6 +114,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -127,6 +135,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -144,6 +154,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -161,6 +173,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -178,6 +192,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -195,6 +211,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -215,6 +233,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -239,6 +259,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -264,6 +286,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -288,6 +312,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -307,6 +333,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -328,6 +356,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -350,6 +380,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -373,6 +405,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -394,6 +428,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -416,6 +452,8 @@ describe('JobButton', () => { <JobButtonComponent ref={ref} job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -438,6 +476,8 @@ describe('JobButton', () => { <JobButtonComponent ref={ref} job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -457,6 +497,8 @@ describe('JobButton', () => { <JobButtonComponent ref={ref} job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -476,6 +518,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} @@ -495,6 +539,8 @@ describe('JobButton', () => { render( <JobButtonComponent job={job} + failureClassificationId={job.failure_classification_id} + resultStatus={job.resultStatus} filterModel={filterModel} visible filterPlatformCb={filterPlatformCb} diff --git a/ui/job-view/pushes/JobButton.jsx b/ui/job-view/pushes/JobButton.jsx index 4ece2f64c3c..bddfa0441c4 100644 --- a/ui/job-view/pushes/JobButton.jsx +++ b/ui/job-view/pushes/JobButton.jsx @@ -11,7 +11,18 @@ import { getBtnClass, formatDuration } from '../../helpers/job'; import { useJobButtonRegistry } from '../../hooks/useJobButtonRegistry'; const JobButtonComponent = forwardRef(function JobButtonComponent( - { job, filterModel, visible, filterPlatformCb, intermittent = false }, + { + job, + filterModel, + visible, + filterPlatformCb, + intermittent = false, + // These scalar props exist so that memo() can detect changes: the job + // object itself is mutated in place when e.g. a classification is + // saved, so comparing the job reference alone would never re-render. + failureClassificationId, + resultStatus, + }, ref, ) { const { @@ -35,13 +46,7 @@ const JobButtonComponent = forwardRef(function JobButtonComponent( [job, visible, setSelected, toggleRunnableSelected, refilter], ); - const { - state, - failure_classification_id: jobFailureClassificationId, - id, - job_type_symbol: jobTypeSymbol, - resultStatus: jobResultStatus, - } = job; + const { state, id, job_type_symbol: jobTypeSymbol } = job; const onMouseEnter = useCallback( (e) => { @@ -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}