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 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(
{
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}