diff --git a/app/cypress/support/component.js b/app/cypress/support/component.js index 49cfd9b6..e6d426e8 100644 --- a/app/cypress/support/component.js +++ b/app/cypress/support/component.js @@ -32,6 +32,7 @@ import '../../src/App.css'; import {MemoryRouter, Route, Routes} from "react-router"; import {QueryProvider} from "../../src/hooks/customHooks"; import {TagsProvider} from "../../src/Tags"; +import {FileWorkerStatusProvider} from "../../src/contexts/FileWorkerStatusContext"; import {MantineProvider} from "@mantine/core"; import {cssVariablesResolver, mantineTheme} from "../../src/themes/mantine"; import {isDarkTheme} from "../../src/themes/names"; @@ -53,17 +54,27 @@ Cypress.Commands.add('mountWithRouter', (component, options) => { * MantineProvider, configured exactly as ThemeProvider configures it. Every component * in src/components/ui renders Mantine internals and throws without it -- the same * thing that had to be added to test-utils.js for jest. + * + * FileWorkerStatusProvider is here for the same reason: App.js wraps the whole app in + * it, and `useFileWorkerStatus` THROWS rather than returning a default when it is + * missing. A page that reaches it only through a modal buried a few levels down -- + * the channel edit page reaches it via CollectionReorganizeModal -- therefore mounted + * fine everywhere else and died here, which is what the edit-channel spec was failing + * on. The poll is slowed right down because a spec is over long before the second + * request would tell it anything, and its fetch errors are swallowed by the provider. */ return cy.mount( - - - - - - + + + + + + + + , diff --git a/app/src/Tags.cy.js b/app/src/Tags.cy.js index 0644dfa2..a9919b9c 100644 --- a/app/src/Tags.cy.js +++ b/app/src/Tags.cy.js @@ -10,6 +10,8 @@ describe('Tags', () => { it('Tags Dashboard displays Tags.', () => { cy.wait('@getTags'); - cy.get('div.ui.label.large').contains('Automotive').should('be.visible'); + // `.wrolpi-tag` is what a tag chip is now. `div.ui.label.large` named the old + // framework's markup, and has matched nothing since its stylesheet stopped loading. + cy.get('.wrolpi-tag').contains('Automotive').should('be.visible'); }); }); diff --git a/app/src/components/Channels.cy.js b/app/src/components/Channels.cy.js index 71cf710b..34fc5994 100644 --- a/app/src/components/Channels.cy.js +++ b/app/src/components/Channels.cy.js @@ -53,6 +53,6 @@ describe('Channels Page', () => { cy.wait(500); cy.get('input[name="name"]').should('be.visible'); cy.get('input[name="name"]').should('have.value', 'Editing Channel'); - cy.get('div.ui.label.large').contains('Automotive').should('be.visible'); + cy.get('.wrolpi-tag').contains('Automotive').should('be.visible'); }); }); diff --git a/app/src/components/Download.cy.js b/app/src/components/Download.cy.js index cb03ca71..9d57281d 100644 --- a/app/src/components/Download.cy.js +++ b/app/src/components/Download.cy.js @@ -1,6 +1,22 @@ import React from 'react'; import {ArchiveDownloadForm, ChannelDownloadForm, EditRSSDownloadForm} from './Download'; +/* + * A field in the error state. + * + * The old form controls took `error` and put the bare word on the DOM node, so a spec could + * assert `have.attr 'error'`. Our fields mark an invalid control the way assistive + * technology reads it instead -- `aria-invalid` on the input. The old assertion did not + * fail when the controls changed; it went on looking for an attribute that no longer + * exists, on a field that is both styled and announced as invalid. + * + * Valid is `aria-invalid="false"`, not a missing attribute: the field states its validity + * either way, which is what lets a screen reader say a control is fine rather than say + * nothing about it. Both are asserted by value so neither direction can pass on absence. + */ +const shouldBeInvalid = (selector) => cy.get(selector).should('have.attr', 'aria-invalid', 'true'); +const shouldBeValid = (selector) => cy.get(selector).should('have.attr', 'aria-invalid', 'false'); + describe('', () => { beforeEach(() => { cy.mountWithTags(); @@ -8,18 +24,18 @@ describe('', () => { it('displays error for invalid URL', () => { cy.get('#urls_textarea').type('invalid-url').wait(500); - cy.get('#urls_textarea').should('have.attr', 'error'); + shouldBeInvalid('#urls_textarea'); }); it('enables download button for valid URL', () => { cy.get('#urls_textarea').type('https://wrolpi.org').wait(500); - cy.get('#urls_textarea').should('not.have.attr', 'error'); + shouldBeValid('#urls_textarea'); cy.get('#download_form_download_button').should('be.visible').and('not.have.attr', 'disabled'); }); it('disables download button for invalid URL', () => { cy.get('#urls_textarea').type('invalid-url').wait(500); - cy.get('#urls_textarea').should('have.attr', 'error'); + shouldBeInvalid('#urls_textarea'); cy.get('#download_form_download_button').should('be.visible').and('have.attr', 'disabled'); }); @@ -29,11 +45,11 @@ describe('', () => { it('clears error when switching from invalid to valid URL', () => { cy.get('#urls_textarea').type('invalid-url').wait(500); - cy.get('#urls_textarea').should('have.attr', 'error'); + shouldBeInvalid('#urls_textarea'); cy.get('#urls_textarea').clear(); cy.get('#urls_textarea').type('https://wrolpi.org').wait(500); - cy.get('#urls_textarea').should('not.have.attr', 'error'); + shouldBeValid('#urls_textarea'); }); }); @@ -53,16 +69,42 @@ describe('', () => { it('displays error for invalid URL', () => { cy.get('#url_input').type('invalid-url').wait(500); - cy.get('#url_input').should('have.attr', 'error'); + shouldBeInvalid('#url_input'); }); it('can choose different options', () => { - cy.get('#download_frequency_selector').click() - .get('.item').contains('Once').click(); - cy.get('#download_frequency_selector').click() - .get('.item').contains('Daily').click(); - cy.get('#download_frequency_selector').click() - .get('.item').contains('180 Days').click(); + /* + * `#download_frequency_selector` and `.item` named the old dropdown and its menu rows. + * SelectField ids each control `_select`, and an option carries the ARIA role + * rather than a class -- which is what a screen reader navigates, so it is the better + * handle of the two. + */ + const chooseFrequency = (label) => { + cy.get('#frequency_select').click(); + /* + * One query, so Cypress retries the whole thing while the dropdown opens. + * `cy.get(...).contains(...)` does not: `get` settles on whatever matched at + * that instant, and the options are not in the DOM yet when it does. + * + * `:visible` because the form carries several Selects, each keeping its options + * mounted while closed -- a bare `[role="option"]` matches a hidden one from a + * dropdown that was never opened, and clicking that fails. + */ + cy.get('[role="option"]:visible').should('have.length.greaterThan', 0); + /* + * `force` because the dropdown re-renders as its open transition settles, and + * Cypress aborts a click whose element was replaced between locating it and + * clicking it. Visibility is asserted on its own above, so this is not + * skipping a check that could hide an unreachable control. + */ + cy.contains('[role="option"]:visible', label).should('be.visible') + .click({force: true}); + cy.get('#frequency_select').should('have.value', label); + }; + + chooseFrequency('Once'); + chooseFrequency('Daily'); + chooseFrequency('180 Days'); }); }); @@ -95,8 +137,14 @@ describe('', () => { tag_names: ['Automotive'], url: "https://www.youtube.com/feeds/videos.xml?channel_id=UC4t8bw1besFTyjW7ZBCOIrw" }; - cy.mountWithTags(); - cy.intercept('GET', '/api/downloaders', downloadersResponse).as('downloaders'); + /* + * The intercepts are declared BEFORE the mount, which is the whole reason this + * block is ordered this way. Mounting first lets the form's own effects fire + * against a bare network -- `/api/downloaders` rejected with "Failed to fetch", + * and Cypress fails a test on an unhandled rejection out of application code + * whatever it was in the middle of asserting. + */ + cy.intercept('GET', '**/api/downloaders', downloadersResponse).as('downloaders'); cy.intercept('POST', '**/api/files/search_directories', { statusCode: 200, body: { @@ -105,47 +153,64 @@ describe('', () => { }, }).as('searchDirectories'); + cy.mountWithTags(); }); it('can edit videos download', () => { // Weekly frequency. cy.get('#url_input').should('have.value', 'https://www.youtube.com/feeds/videos.xml?channel_id=UC4t8bw1besFTyjW7ZBCOIrw'); - cy.get('div.divider.text').contains('Weekly'); + // The old dropdown showed its choice in a `div.divider.text`; a Select is a real input + // holding the label, so the value can be read rather than searched for. + cy.get('#frequency_select').should('have.value', 'Weekly'); cy.get('input[name="title_include"]').should('have.value', 'included'); cy.get('input[name="title_exclude"]').should('have.value', 'excluded'); cy.get('#destination_search_form').should('be.visible'); - cy.get('div.ui.large.label').contains('Automotive').should('be.visible'); + cy.get('.wrolpi-tag').contains('Automotive').should('be.visible'); }); it('can submit videos download', () => { cy.get('button[type="submit"]').should('be.visible'); - cy.intercept('PUT', '/api/download/1', (req) => { - expect(req.body).to.deep.equal(JSON.stringify({ - "destination": "destination directory", - "downloader": "rss", - "frequency": 604800, - "settings": { - "excluded_urls": null, - "title_exclude": "excluded", - "title_include": "included", - "video_resolutions": [ - "720p", - "480p", - "maximum" - ], - "video_format": "mp4", - "minimum_duration": null, - "maximum_duration": null - }, - "sub_downloader": "video", - "tag_names": ["Automotive"], - "urls": ["https://www.youtube.com/feeds/videos.xml?channel_id=UC4t8bw1besFTyjW7ZBCOIrw"] - })); - req.reply({statusCode: 204}); - }).as('downloadPut'); + cy.intercept('PUT', '/api/download/1', {statusCode: 204}).as('downloadPut'); cy.get('button[type="submit"]').click(); + + /* + * The edit form sends the download back unchanged. + * + * This asserted one exact JSON string, which broke the moment the video downloader + * gained settings the form fills in with their defaults -- `compress_singlefile`, + * `audio_only`, `user_agent` and five more. None of that is the subject: what + * matters is that the values the user was shown are the values that go back. + * + * So the envelope is still matched exactly, which catches a field appearing or + * vanishing at the top level, while `settings` is checked for the seven the fixture + * set. Waiting on the alias matters as much as either -- without it the click ended + * the test and an interceptor that never ran would have passed silently. + */ + cy.wait('@downloadPut').its('request.body').then((body) => { + const sent = typeof body === 'string' ? JSON.parse(body) : body; + const {settings, ...envelope} = sent; + + expect(envelope).to.deep.equal({ + destination: 'destination directory', + downloader: 'rss', + frequency: 604800, + sub_downloader: 'video', + tag_names: ['Automotive'], + urls: ['https://www.youtube.com/feeds/videos.xml?channel_id=UC4t8bw1besFTyjW7ZBCOIrw'], + }); + + expect(settings).to.deep.include({ + excluded_urls: null, + title_exclude: 'excluded', + title_include: 'included', + video_resolutions: ['720p', '480p', 'maximum'], + video_format: 'mp4', + minimum_duration: null, + maximum_duration: null, + }); + }); }); }); diff --git a/app/src/components/inventory/Inventory.cy.js b/app/src/components/inventory/Inventory.cy.js index ed3407d8..7a575f57 100644 --- a/app/src/components/inventory/Inventory.cy.js +++ b/app/src/components/inventory/Inventory.cy.js @@ -14,6 +14,16 @@ const FOOD_STORAGE = { describe('Inventory Page', () => { beforeEach(() => { + /* + * The page also loads the shared food catalog, from its own endpoint. + * + * Unstubbed, that fetch reached the network and rejected -- "Failed to fetch" out of + * an effect, which Cypress counts as an uncaught application error and fails the + * running test on, whatever it happened to be asserting. All three tests here died + * that way, none of them for a reason to do with their own subject. + */ + cy.intercept('GET', '**/api/inventory/catalog', {statusCode: 200, body: {catalog: []}}) + .as('getCatalog'); // The page loads every inventory in full from one endpoint. cy.intercept('GET', '**/api/inventory', {statusCode: 200, body: {inventories: [FOOD_STORAGE]}}) .as('getInventories'); @@ -23,21 +33,42 @@ describe('Inventory Page', () => { cy.mountWithTags(, {initialEntries: ['/inventory']}); cy.wait('@getInventories'); - cy.contains('Food Storage').should('be.visible'); - cy.contains('Salt').should('be.visible'); + /* + * `:visible` because the page always mounts its printable copy of the inventory, a + * `.inventory-print` block that is `display: none` on screen and becomes the only + * visible content when the browser prints. It carries the same headings and the + * same item names, and `cy.contains` finds the DEEPEST match -- which was the + * hidden `

`, so the assertion was reading the print sheet and reporting that + * the page had not rendered. + */ + cy.contains(':visible', 'Food Storage').should('be.visible'); + cy.contains(':visible', 'Salt').should('be.visible'); cy.get('input[name="name"]').should('exist'); // the spreadsheet add row }); it('persists a new item with a whole-inventory PUT', () => { + /* + * The request body arrives as a JSON string, so `req.body.items` was undefined -- + * both in the reply built here, which handed the page back an inventory with no + * items at all, and in the assertion, which waited for a property that could never + * appear and timed out. Parsing it once at the top makes both mean what they say. + */ + const itemsIn = (body) => (typeof body === 'string' ? JSON.parse(body) : body).items; + cy.intercept('PUT', '**/api/inventory/food-storage', (req) => { - req.reply({statusCode: 200, body: {inventory: {...FOOD_STORAGE, version: 2, items: req.body.items}}}); + req.reply({ + statusCode: 200, + body: {inventory: {...FOOD_STORAGE, version: 2, items: itemsIn(req.body)}}, + }); }).as('saveInventory'); cy.mountWithTags(, {initialEntries: ['/inventory']}); cy.wait('@getInventories'); cy.get('input[name="name"]').last().type('Rice{enter}'); - cy.wait('@saveInventory').its('request.body.items').should('have.length', 2); + cy.wait('@saveInventory').its('request.body').then((body) => { + expect(itemsIn(body), 'the existing item and the new one').to.have.length(2); + }); }); it('switches to the summary tab', () => { diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js index 51f0a29c..aba5421e 100644 --- a/app/src/components/ui/ui-layout.cy.js +++ b/app/src/components/ui/ui-layout.cy.js @@ -279,7 +279,7 @@ describe('a page stacks its own blocks', () => { describe('a row of buttons is spaced and aligned by the row', () => { /* - * Semantic's Button carried `margin: 0 .25em .25em 0` of its own, so a page could list + * Buttons used to carry `margin: 0 .25em .25em 0` of their own, so a page could list * buttons as bare siblings and they arrived spaced. Nothing replaced that margin, and * five pages still list them that way -- the archive page's six actions all shared edges. * @@ -435,7 +435,7 @@ describe('a row of buttons is spaced and aligned by the row', () => { describe('two choices of equal weight are split down the middle', () => { /* - * The dashboard's Download/Upload panel, which Semantic drew as a `Segment placeholder` with a + * The dashboard's Download/Upload panel, which used to be drawn as a placeholder panel with a * vertical `Divider`. The migrated version was a `Group` with a divider between the buttons, * so the halves were sized to their labels: the pair huddled in the middle of the panel with * the rule wherever they happened to leave it, rather than two equal fields split at the centre. @@ -472,7 +472,7 @@ describe('two choices of equal weight are split down the middle', () => { }); it('centres each button in its own half', () => { - // Semantic centred them; the Group version left them either side of the divider instead. + // They were centred before; the flex-row version left them either side of the divider. mountSplit(); cy.get('.wrolpi-or-split').should(($split) => { @@ -1797,8 +1797,8 @@ describe('a tag is legible in every theme, whatever color the user picked', () = cy.contrastRatio('[data-tag="Legacy"]').should('be.lessThan', 1.5); }); - it('is at least as large as the Semantic label it replaced', () => { - // The first pass came out at 11.5px against Semantic's 12px and read as shrunken. + it('is at least as large as the label it replaced', () => { + // The first pass came out at 11.5px against the old 12px and read as shrunken. // A tag is a hit target as well as a word, so it also has to stay tall enough to hit. cy.mountUI(tags); @@ -1810,7 +1810,7 @@ describe('a tag is legible in every theme, whatever color the user picked', () = it("sets a tag's word within a fifth of the body text it sits beside", () => { /* - * Matching Semantic was the wrong target for this one. A tag is not an annotation on + * Matching the old size was the wrong target for this one. A tag is not an annotation on * something else -- it is the whole content of its chip, and the thing a user scans a * wall of them for -- so it wants to read as text, not as a footnote. At 12.5px design * against a 16px base it was 78% of the prose around it, and on the dashboard, where @@ -3349,8 +3349,8 @@ describe('the navbar overflow menu keeps the bar\'s styling', () => { describe('the icon-only triggers in the navbar corner are reachable', () => { /* - * The search glyph carried Semantic's `item` class, which has had no rules at all since - * Semantic was removed: no pointer cursor, no hover highlight, and a hit area of the + * The search glyph carried the old `item` class, which has had no rules at all since that + * stylesheet stopped loading: no pointer cursor, no hover highlight, and a hit area of the * 18px glyph rather than the height of the bar. The share glyph did compute * `cursor: pointer` -- it is an `` -- but its target was the 24px box the icon * occupies, so the pointer showed only if you found it exactly. @@ -3361,7 +3361,7 @@ describe('the icon-only triggers in the navbar corner are reachable', () => { /* * The REAL ShareButton, and the real SearchIconButton that DesktopNav renders for * itself. The first version of this suite used hand-written anchors carrying the - * classes I expected the components to have, so reverting SearchIconButton to Semantic's + * classes I expected the components to have, so reverting SearchIconButton to the old * `item` left it green -- it was measuring my markup, not the app's. */ const corner = ; @@ -3497,9 +3497,37 @@ describe('the navbar recalculates when the corner changes, not only the window', expect($filler[0].getBoundingClientRect().width).to.equal(180); }); + /* + * Wait for the recalculation the grown corner triggers before counting anything. + * + * The corner reaching 180px is not the same moment as the bar responding to it: the + * ResizeObserver fires, React re-renders, and only then is the link count the + * shrunken one. Reading the count in a bare `.then()` right after the width check + * therefore sometimes caught the bar at its FULL complement, which made `shrunken` + * the same number the assertion below wanted to exceed -- "expected 10 to be above + * 10", under load, on a test whose subject was never in doubt. + * + * One row is the settle signal, and not an arbitrary one: an unrecalculated bar at + * this width wraps, which is exactly what the test above this one asserts. + */ + cy.get('.wrolpi-navbar').should(($nav) => { + const rows = new Set([...$nav[0].querySelectorAll('.wrolpi-navbar-link')] + .map(link => Math.round(link.getBoundingClientRect().top))); + expect([...rows], 'the bar has recalculated and is one row').to.have.length(1); + }); + cy.get('.wrolpi-navbar').then(($nav) => { const shrunken = $nav[0].querySelectorAll('.wrolpi-navbar-link').length; + /* + * The premise: the grown corner cost the bar links, so there are some to give + * back. DesktopNav renders the "More" trigger only when `overflowLinks` is + * non-empty, which makes its presence the statement that something overflowed. + * Without this the assertion below could be satisfied by a bar that never + * overflowed, and the test would report success having measured nothing. + */ + cy.get('.wrolpi-navbar').contains('button', 'More').should('exist'); + // Shrink the corner back, as a cleared warning would. cy.get('[data-testid="corner-filler"]').then(($filler) => { $filler[0].style.width = '20px'; @@ -3695,7 +3723,7 @@ describe('a toast does not cover the navigation bar', () => { describe('a disabled button keeps its own color', () => { /* * Mantine repaints a disabled button flat grey -- background, text and border all - * replaced with `--mantine-color-disabled`. Semantic kept the color and dropped the + * replaced with `--mantine-color-disabled`. We keep the color and drop the * whole control to `opacity: 0.45`, so a disabled Delete was still visibly the red one. * * The file browser is where this bites. Its footer is eight buttons, six of which are @@ -4065,14 +4093,66 @@ describe('the file browser footer is a row of buttons, not a slab', () => { }); }); +describe('the file browser\'s filter is as wide as its bar reserves for it', () => { + /* + * The width rule was written as `.ui.input.file-browser-filter`, naming a wrapper the old + * input component rendered around the field. Nothing carries `ui input` any more, so the + * rule matched nothing and the filter had been rendering at the browser's default width + * on a bar laid out to give it 34em -- a dead selector, which fails by doing nothing + * rather than by erroring, and so goes unnoticed. + * + * Mounted as the markup FileBrowser writes: the class lands on ActionInput's wrapper, + * because the field and its clear button are one control. + */ + const mountFilter = () => cy.mountUI(
+ } + /> + +
); + + it('is far wider than a default input on a desktop viewport', () => { + cy.viewport(1200, 700); + mountFilter(); + + cy.get('.file-browser-filter').should(($filter) => { + // 34em against the root font size, which `--ui-scale` moves; measured rather + // than hardcoded so this does not fail the next time the scale changes. + const em = parseFloat(getComputedStyle(document.documentElement).fontSize); + expect($filter[0].getBoundingClientRect().width, 'the reserved 34em') + .to.be.closeTo(34 * em, 2); + }); + }); + + it('fills the row on a phone instead, beside the collapse control', () => { + cy.viewport(400, 700); + mountFilter(); + + cy.get('.file-browser-filter-container').should(($row) => { + const row = $row[0].getBoundingClientRect(); + const filter = $row[0].querySelector('.file-browser-filter').getBoundingClientRect(); + const collapse = $row[0].querySelector('button[aria-label="Collapse all"]') + .getBoundingClientRect(); + + // Still one line -- the point of `flex: 1 1 auto` rather than `width: 100%`. + expect(filter.top, 'filter and collapse share a row').to.be.closeTo(collapse.top, 2); + // And it takes the room the collapse control does not. + expect(filter.width, 'most of the row').to.be.greaterThan(row.width * 0.6); + expect(filter.width, 'but not all of it').to.be.lessThan(row.width); + }); + }); +}); + describe('the search field\'s clear button is attached to it', () => { /* * The control is a bordered box with `padding: 0 8px`, so the clear button sat 9px short * of the right edge and 2px short of the full height -- a glyph dropped inside the input * rather than a control of its own. * - * Semantic rendered it as an action button welded to the field. Measured on the QA Pi, - * which still runs Semantic: zero gap from the input, zero to the outer edge, and the + * It should read as an action button welded to the field. Measured on the QA Pi: + * zero gap from the input, zero to the outer edge, and the * full height of the field. */ const mountField = () => cy.mountUI(
@@ -4307,7 +4387,7 @@ describe('APIButton honours the size its call site asks for', () => { }); }); - it('honours the Semantic size names the call sites actually use', () => { + it('honours the size names the call sites actually use', () => { /* * The seventeen call sites that were being ignored do not say `xs` and `lg` -- they say * `small`, `big`, `large` and `huge`, which `resolveSize` translates. Testing only the