/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ /* eslint-disable qunit/require-expect */ import { click, currentRouteName, currentURL, typeIn, visit, waitFor, waitUntil, } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { Response } from 'ember-cli-mirage'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; import { selectChoose, clickTrigger, } from 'ember-power-select/test-support/helpers'; import { generateAcceptanceTestEvalMock } from '../../mirage/utils'; import percySnapshot from '@percy/ember'; import faker from 'nomad-ui/mirage/faker'; const getStandardRes = () => [ { CreateIndex: 1249, CreateTime: 1640181894162724000, DeploymentID: '12efbb28-840e-7794-b215-a7b112e40a4f', ID: '5fb1b8cd-00f8-fff8-de0c-197dc37f5053', JobID: 'cores-example', JobModifyIndex: 694, ModifyIndex: 1251, ModifyTime: 1640181894167194000, Namespace: 'ted-lasso', Priority: 50, QueuedAllocations: { lb: 0, webapp: 0, }, SnapshotIndex: 1249, Status: 'complete', TriggeredBy: 'job-register', Type: 'service', }, { CreateIndex: 1304, CreateTime: 1640183201719510000, DeploymentID: '878435bf-7265-62b1-7902-d45c44b23b79', ID: '66cb98a6-7740-d5ef-37e4-fa0f8b1de44b', JobID: 'cores-example', JobModifyIndex: 1304, ModifyIndex: 1306, ModifyTime: 1640183201721418000, Namespace: 'default', Priority: 50, QueuedAllocations: { webapp: 0, lb: 0, }, SnapshotIndex: 1304, Status: 'complete', TriggeredBy: 'job-register', Type: 'service', }, { CreateIndex: 1267, CreateTime: 1640182198255685000, DeploymentID: '12efbb28-840e-7794-b215-a7b112e40a4f', ID: '78009518-574d-eee6-919a-e83879175dd3', JobID: 'cores-example', JobModifyIndex: 1250, ModifyIndex: 1274, ModifyTime: 1640182228112823000, Namespace: 'ted-lasso', PreviousEval: '84f1082f-3e6e-034d-6df4-c6a321e7bd63', Priority: 50, QueuedAllocations: { lb: 0, }, SnapshotIndex: 1272, Status: 'complete', TriggeredBy: 'alloc-failure', Type: 'service', WaitUntil: '2021-12-22T14:10:28.108136Z', }, { CreateIndex: 1322, CreateTime: 1640183505760099000, DeploymentID: '878435bf-7265-62b1-7902-d45c44b23b79', ID: 'c184f72b-68a3-5180-afd6-af01860ad371', JobID: 'cores-example', JobModifyIndex: 1305, ModifyIndex: 1329, ModifyTime: 1640183535540881000, Namespace: 'default', PreviousEval: '9a917a93-7bc3-6991-ffc9-15919a38f04b', Priority: 50, QueuedAllocations: { lb: 0, }, SnapshotIndex: 1326, Status: 'complete', TriggeredBy: 'alloc-failure', Type: 'service', WaitUntil: '2021-12-22T14:32:15.539556Z', }, ]; module('Acceptance | evaluations list', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); test('it passes an accessibility audit', async function (assert) { assert.expect(2); await visit('/evaluations'); assert.equal( currentRouteName(), 'evaluations.index', 'The default route in evaluations is evaluations index' ); await a11yAudit(assert); }); test('it renders an empty message if there are no evaluations rendered', async function (assert) { faker.seed(1); await visit('/evaluations'); assert.expect(2); await percySnapshot(assert); assert .dom('[data-test-empty-evaluations-list]') .exists('We display empty table message.'); assert .dom('[data-test-no-eval]') .exists('We display a message saying there are no evaluations.'); }); test('it renders a list of evaluations', async function (assert) { faker.seed(1); assert.expect(3); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: '', reverse: 'true', }, 'Forwards the correct query parameters on default query when route initially loads' ); return getStandardRes(); }); await visit('/evaluations'); await percySnapshot(assert); assert .dom('[data-test-eval-table]') .exists('Evaluations table should render'); assert .dom('[data-test-evaluation]') .exists({ count: 4 }, 'Should render the correct number of evaluations'); }); module('filters', function () { test('it should enable filtering by evaluation status', async function (assert) { assert.expect(2); server.get('/evaluations', getStandardRes); await visit('/evaluations'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: 'Status contains "pending"', reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await clickTrigger('[data-test-evaluation-status-facet]'); await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); assert .dom('[data-test-no-eval-match]') .exists('Renders a message saying no evaluations match filter status'); }); test('it should enable filtering by namespace', async function (assert) { assert.expect(2); server.get('/evaluations', getStandardRes); await visit('/evaluations'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: 'default', per_page: '25', next_token: '', filter: '', reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await clickTrigger('[data-test-evaluation-namespace-facet]'); await selectChoose('[data-test-evaluation-namespace-facet]', 'default'); assert .dom('[data-test-empty-evaluations-list]') .exists('Renders a message saying no evaluations match filter status'); }); test('it should enable filtering by triggered by', async function (assert) { assert.expect(2); server.get('/evaluations', getStandardRes); await visit('/evaluations'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: `TriggeredBy contains "periodic-job"`, reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await clickTrigger('[data-test-evaluation-triggered-by-facet]'); await selectChoose( '[data-test-evaluation-triggered-by-facet]', 'Periodic Job' ); assert .dom('[data-test-empty-evaluations-list]') .exists('Renders a message saying no evaluations match filter status'); }); test('it should enable filtering by type', async function (assert) { assert.expect(2); server.get('/evaluations', getStandardRes); await visit('/evaluations'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: 'NodeID is not empty', reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await clickTrigger('[data-test-evaluation-type-facet]'); await selectChoose('[data-test-evaluation-type-facet]', 'Client'); assert .dom('[data-test-empty-evaluations-list]') .exists('Renders a message saying no evaluations match filter status'); }); test('it should enable filtering by search term', async function (assert) { assert.expect(2); server.get('/evaluations', getStandardRes); await visit('/evaluations'); const searchTerm = 'Lasso'; server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: `ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}"`, reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await typeIn('[data-test-evaluations-search] input', searchTerm); assert .dom('[data-test-empty-evaluations-list]') .exists('Renders a message saying no evaluations match filter status'); }); test('it should enable combining filters and search', async function (assert) { assert.expect(5); server.get('/evaluations', getStandardRes); await visit('/evaluations'); const searchTerm = 'Lasso'; server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: `ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}"`, reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await typeIn('[data-test-evaluations-search] input', searchTerm); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: `(ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}") and NodeID is not empty`, reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await clickTrigger('[data-test-evaluation-type-facet]'); await selectChoose('[data-test-evaluation-type-facet]', 'Client'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: `NodeID is not empty`, reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await click('[data-test-evaluations-search] button'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: `NodeID is not empty and Status contains "complete"`, reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return []; }); await clickTrigger('[data-test-evaluation-status-facet]'); await selectChoose('[data-test-evaluation-status-facet]', 'Complete'); assert .dom('[data-test-empty-evaluations-list]') .exists('Renders a message saying no evaluations match filter status'); }); }); module('page size', function (hooks) { hooks.afterEach(function () { // PageSizeSelect and the Evaluations Controller are both using localStorage directly // Will come back and invert the dependency window.localStorage.clear(); }); test('it is possible to change page size', async function (assert) { assert.expect(1); server.get('/evaluations', getStandardRes); await visit('/evaluations'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '50', next_token: '', filter: '', reverse: 'true', }, 'It makes a request with the per_page set by the user' ); return getStandardRes(); }); await clickTrigger('[data-test-per-page]'); await selectChoose('[data-test-per-page]', 50); }); }); module('pagination', function () { test('it should enable pagination by using next tokens', async function (assert) { assert.expect(7); server.get('/evaluations', function () { return new Response( 200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes() ); }); await visit('/evaluations'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: 'next-token-1', filter: '', reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return new Response( 200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes() ); }); assert .dom('[data-test-eval-pagination-next]') .isEnabled( 'If there is a next-token in the API response the next button should be enabled.' ); await click('[data-test-eval-pagination-next]'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: 'next-token-2', filter: '', reverse: 'true', }, 'It makes another server request using the options selected by the user' ); return getStandardRes(); }); await click('[data-test-eval-pagination-next]'); assert .dom('[data-test-eval-pagination-next]') .isDisabled('If there is no next-token, the next button is disabled.'); assert .dom('[data-test-eval-pagination-prev]') .isEnabled( 'After we transition to the next page, the previous page button is enabled.' ); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: 'next-token-1', filter: '', reverse: 'true', }, 'It makes a request using the stored old token.' ); return new Response( 200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes() ); }); await click('[data-test-eval-pagination-prev]'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: '', reverse: 'true', }, 'When there are no more stored previous tokens, we will request with no next-token.' ); return new Response( 200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes() ); }); await click('[data-test-eval-pagination-prev]'); }); test('it should clear all query parameters on refresh', async function (assert) { assert.expect(1); server.get('/evaluations', function () { return new Response( 200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes() ); }); await visit('/evaluations'); server.get('/evaluations', function () { return getStandardRes(); }); await click('[data-test-eval-pagination-next]'); await clickTrigger('[data-test-evaluation-status-facet]'); await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: '', reverse: 'true', }, 'It clears all query parameters when making a refresh' ); return new Response( 200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes() ); }); await click('[data-test-eval-refresh]'); }); test('it should reset pagination when filters are applied', async function (assert) { assert.expect(1); server.get('/evaluations', function () { return new Response( 200, { 'x-nomad-nexttoken': 'next-token-1' }, getStandardRes() ); }); await visit('/evaluations'); server.get('/evaluations', function () { return new Response( 200, { 'x-nomad-nexttoken': 'next-token-2' }, getStandardRes() ); }); await click('[data-test-eval-pagination-next]'); server.get('/evaluations', getStandardRes); await click('[data-test-eval-pagination-next]'); server.get('/evaluations', function (_server, fakeRequest) { assert.deepEqual( fakeRequest.queryParams, { namespace: '*', per_page: '25', next_token: '', filter: 'Status contains "pending"', reverse: 'true', }, 'It clears all next token when filtered request is made' ); return getStandardRes(); }); await clickTrigger('[data-test-evaluation-status-facet]'); await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); }); }); module('resource linking', function () { test('it should generate a link to the job resource', async function (assert) { server.create('node'); const job = server.create('job', { id: 'example', shallow: true }); server.create('evaluation', { jobId: job.id }); await visit('/evaluations'); assert .dom('[data-test-evaluation-resource]') .hasText( job.name, 'It conditionally renders the correct resource name' ); await click('[data-test-evaluation-resource]'); assert .dom('[data-test-job-name]') .includesText(job.name, 'We navigate to the correct job page.'); }); test('it should generate a link to the node resource', async function (assert) { const node = server.create('node'); server.create('evaluation', { nodeId: node.id }); await visit('/evaluations'); const shortNodeId = node.id.split('-')[0]; assert .dom('[data-test-evaluation-resource]') .hasText( shortNodeId, 'It conditionally renders the correct resource name' ); await click('[data-test-evaluation-resource]'); assert .dom('[data-test-title]') .includesText(node.name, 'We navigate to the correct client page.'); }); }); module('evaluation detail', function () { test('clicking an evaluation opens the detail view', async function (assert) { faker.seed(1); server.get('/evaluations', getStandardRes); server.get('/evaluation/:id', function (_, { queryParams, params }) { const expectedNamespaces = ['default', 'ted-lasso']; assert.notEqual( expectedNamespaces.indexOf(queryParams.namespace), -1, 'Eval details request has namespace query param' ); return { ...generateAcceptanceTestEvalMock(params.id), ID: params.id }; }); await visit('/evaluations'); const evalId = '5fb1b8cd'; await click(`[data-test-evaluation='${evalId}']`); await percySnapshot(assert); assert .dom('[data-test-eval-detail-is-open]') .exists( 'A sidebar portal mounts to the dom after clicking an evaluation' ); assert .dom('[data-test-rel-eval]') .exists( { count: 12 }, 'all related evaluations and the current evaluation are displayed' ); click(`[data-test-rel-eval='fd1cd898-d655-c7e4-17f6-a1a2e98b18ef']`); await waitFor('[data-test-eval-loading]'); assert .dom('[data-test-eval-loading]') .exists( 'transition to loading state after clicking related evaluation' ); await waitFor('[data-test-eval-detail-header]'); assert.equal( currentURL(), '/evaluations?currentEval=fd1cd898-d655-c7e4-17f6-a1a2e98b18ef' ); assert .dom('[data-test-title]') .includesText('fd1cd898', 'New evaluation hash appears in the title'); await click(`[data-test-evaluation='66cb98a6']`); assert.equal( currentURL(), '/evaluations?currentEval=66cb98a6-7740-d5ef-37e4-fa0f8b1de44b', 'Clicking an evaluation in the table updates the sidebar' ); click('[data-test-eval-sidebar-x]'); // We wait until the sidebar closes since it uses a transition of 300ms await waitUntil( () => !document.querySelector('[data-test-eval-detail-is-open]') ); assert.equal( currentURL(), '/evaluations', 'When the user clicks the x button the sidebar closes' ); }); test('it should provide an error state when loading an invalid evaluation', async function (assert) { server.get('/evaluations', getStandardRes); server.get('/evaluation/:id', function () { return new Response(404, {}, ''); }); await visit('/evaluations'); const evalId = '5fb1b8cd'; await click(`[data-test-evaluation='${evalId}']`); assert .dom('[data-test-eval-detail-is-open]') .exists( 'A sidebar portal mounts to the dom after clicking an evaluation' ); assert .dom('[data-test-eval-error]') .exists( 'all related evaluations and the current evaluation are displayed' ); click('[data-test-eval-sidebar-x]'); // We wait until the sidebar closes since it uses a transition of 300ms await waitUntil( () => !document.querySelector('[data-test-eval-detail-is-open]') ); assert.equal( currentURL(), '/evaluations', 'When the user clicks the x button the sidebar closes' ); }); }); });