diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 801f12a8e..a5d31e47b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,11 +72,15 @@ jobs: runTests: false working-directory: . + - name: Download test data + run: pnpm test-data small + - name: E2E test uses: cypress-io/github-action@v6 with: + build: npx cypress info install: false - command: pnpm test:e2e + command: env CI=true pnpm test:e2e working-directory: apps/web - uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 197b77ae6..aaa9db995 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,5 @@ spacedrive .github/scripts/deps .vite-inspect vite.config.ts.* + +/test-data diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aef24826..f632fbdc4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,13 @@ To quickly run only the desktop app after `prep`, you can use: Also, the react-devtools can be launched using `pnpm dlx react-devtools`. However, it must be executed before starting the desktop app for it to connect. +You can download a bundle with sample files to test the app by running: + +- `pnpm test-data` + + Only for Linux and macOS (Requires curl and tar). + The test files will be located in a directory called `test-data` in the root of the spacedrive repo. + To run the web app: - `pnpm dev:web` @@ -68,15 +75,23 @@ You can launch these individually if you'd prefer: - `cargo run -p sd-server` (server) - `pnpm web dev` (web interface) +To run the e2e tests for the web app: + +- `pnpm web test:e2e` + +If you are developing a new test, you can execute Cypress in interactive mode with: + +- `pnpm web test:interactive` + To run the landing page: - `pnpm landing dev` If you encounter any issues, ensure that you are using the following versions of Rust, Node and Pnpm: -- Rust version: **1.75.0** -- Node version: **18.17** -- Pnpm version: **8.0.0** +- Rust version: **1.75** +- Node version: **18.18** +- Pnpm version: **8.15** After cleaning out your build artifacts using `pnpm clean`, `git clean`, or `cargo clean`, it is necessary to re-run the `setup-system` script. diff --git a/apps/landing/package.json b/apps/landing/package.json index 43e564bdf..60b4066cc 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -56,7 +56,7 @@ "@octokit/openapi-types": "^20.0.0", "@sd/config": "workspace:*", "@svgr/webpack": "^8.1.0", - "@types/node": ">18.x", + "@types/node": ">18.18.x", "@types/react": "^18.2.67", "@types/react-burger-menu": "^2.8.7", "@types/react-dom": "^18.2.22", diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index c5817f957..2da064fb9 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -4,5 +4,5 @@ module.exports = { tsconfigRootDir: __dirname, project: './tsconfig.json' }, - ignorePatterns: ['playwright.config.ts', 'tests/**/*', 'cypress/**/*'] + ignorePatterns: ['playwright.config.ts', 'tests/**/*', 'cypress/**/*', 'cypress.config.ts'] }; diff --git a/apps/web/cypress.config.ts b/apps/web/cypress.config.ts index 0c246f3fb..a3cee49f2 100644 --- a/apps/web/cypress.config.ts +++ b/apps/web/cypress.config.ts @@ -1,5 +1,11 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inspect } from 'node:util'; import { defineConfig } from 'cypress'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + const ci_specific = { // Double all the default timeouts // https://docs.cypress.io/guides/references/configuration#Timeouts @@ -8,16 +14,28 @@ const ci_specific = { taskTimeout: 60000 * 2, pageLoadTimeout: 60000 * 2, requestTimeout: 5000 * 2, - responseTimeout: 30000 * 2 + responseTimeout: 30000 * 2, + // Enable test retries + // https://docs.cypress.io/guides/guides/test-retries#Configure-retry-attempts-for-all-modes + retries: 2 }; -export default defineConfig({ +const config = defineConfig({ e2e: { baseUrl: 'http://localhost:8002', - setupNodeEvents(on, config) { - // implement node event listeners here + setupNodeEvents(on) { + on('task', { + repoRoot: () => { + return resolve(join(__dirname, '../../')); + } + }); } }, video: true, + experimentalWebKitSupport: true, ...(process.env.CI === 'true' ? ci_specific : {}) }); + +console.log('Cypress default config:', inspect(config, { depth: null, colors: true })); + +export default config; diff --git a/apps/web/cypress/e2e/onboarding.spec.cy.ts b/apps/web/cypress/e2e/1-onboarding.spec.cy.ts similarity index 73% rename from apps/web/cypress/e2e/onboarding.spec.cy.ts rename to apps/web/cypress/e2e/1-onboarding.spec.cy.ts index 77c331932..db8952e6e 100644 --- a/apps/web/cypress/e2e/onboarding.spec.cy.ts +++ b/apps/web/cypress/e2e/1-onboarding.spec.cy.ts @@ -1,4 +1,5 @@ -import { discord, libraryName } from '../fixtures/onboarding.json'; +import { discordUrl, libraryName, privacyUrl } from '../fixtures/onboarding.json'; +import { libraryRegex, onboardingRegex } from '../fixtures/routes'; describe('Onboarding', () => { // TODO: Create debug flag to bypass auto language detection @@ -9,8 +10,13 @@ describe('Onboarding', () => { } }); + // Delete previous library if it exists + cy.url() + .should('match', new RegExp(`${libraryRegex.source}|${onboardingRegex.source}`)) + .then((url) => (onboardingRegex.test(url) ? url : cy.deleteLibrary(libraryName))); + // Check redirect to initial alpha onboarding screen - cy.url().should('contain', '/onboarding/alpha'); + cy.url().should('match', /\/onboarding\/alpha$/); // Check application name is present cy.get('h1').should('contain', 'Spacedrive'); @@ -26,7 +32,7 @@ describe('Onboarding', () => { // Check Join Discord button exists and point to a valid discord invite cy.get('button').contains('Join Discord').click(); - cy.get('@winOpen').should('be.calledWith', discord); + cy.get('@winOpen').should('be.calledWith', discordUrl); // Check we have a button to continue to the Library creation cy.get('a') @@ -35,7 +41,7 @@ describe('Onboarding', () => { .click(); // Check we were redirect to Library creation screen - cy.url().should('contain', '/onboarding/new-library'); + cy.url().should('match', /\/onboarding\/new-library$/); // Check create library screen title cy.get('h2').should('contain', 'Create a Library'); @@ -61,10 +67,10 @@ describe('Onboarding', () => { cy.get('@libraryNameInput').type(libraryName); // Check we have a button to continue to the add default locations screen - cy.get('button').contains('New library').click(); + cy.get('@newLibraryButton').click(); // Check redirect to add default locations - cy.url().should('contain', '/onboarding/locations'); + cy.url().should('match', /\/onboarding\/locations$/); // Check we have a Toggle All button cy.get('#toggle-all').as('toggleAllButton'); @@ -107,7 +113,7 @@ describe('Onboarding', () => { cy.get('button').contains('Continue').click(); // Check redirect to privacy screen - cy.url().should('contain', '/onboarding/privacy'); + cy.url().should('match', /\/onboarding\/privacy$/); // Check privacy screen title cy.get('h2').should('contain', 'Your Privacy'); @@ -122,56 +128,22 @@ describe('Onboarding', () => { // Check More info button exists and point to the valid pravacy policy cy.get('button').contains('More info').click(); - cy.get('@winOpen').should( - 'be.calledWith', - 'https://www.spacedrive.com/docs/product/resources/privacy' - ); + cy.get('@winOpen').should('be.calledWith', privacyUrl); // Check we have a button to finish onboarding cy.get('button[type="submit"]').contains('Continue').click(); // Check redirect to privacy screen - cy.url().should('contain', '/onboarding/creating-library'); + cy.url().should('match', /\/onboarding\/creating-library$/); // FIX-ME: This fails a lot, due to the creating library screen only being show for a short time // Check creating library screen title // cy.get('h2').should('contain', 'Creating your library'); // Check redirect to Library - cy.url().should((url) => { - expect(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\//.test(url)).to - .be.true; - }); + cy.checkUrlIsLibrary(); - // Click on the library submenu - cy.get('button[aria-haspopup="menu"]').contains(libraryName).click(); - cy.get('a').contains('Manage Library').click(); - - // Check redirect to Library settings - cy.url().should('contain', '/settings/library/general'); - - // Check Library seetings screen title - cy.get('h1').should('contain', 'Library Settings'); - - // Check Library name is correct - cy.get('label') - .contains('Name') - .parent() - .find('input') - .should((input) => { - expect(input.val()).to.eq(libraryName); - }); - - // Delete Library - cy.get('button').contains('Delete').click(); - - // Check confirmation modal for deleting appears - cy.get('body > div[role="dialog"]').as('deleteModal'); - - // Check modal title - cy.get('@deleteModal').find('h2').should('contain', 'Delete Library'); - - // Confirm delete - cy.get('@deleteModal').find('button').contains('Delete').click(); + // Delete library + cy.deleteLibrary(libraryName); }); }); diff --git a/apps/web/cypress/e2e/2-location.spec.cy.ts b/apps/web/cypress/e2e/2-location.spec.cy.ts new file mode 100644 index 000000000..a4c5fd8b3 --- /dev/null +++ b/apps/web/cypress/e2e/2-location.spec.cy.ts @@ -0,0 +1,88 @@ +import { locationName } from '../fixtures/location.json'; +import { libraryName } from '../fixtures/onboarding.json'; +import { locationRegex } from '../fixtures/routes'; + +describe('Location', () => { + before(() => { + cy.fastOnboarding(libraryName); + }); + + it('Add Location', () => { + // Click on the "Add Location" button + cy.get('button').contains('Add Location').click(); + + // Get the input field for the new location inside modal + cy.get('h2') + .contains('New location') + .parent() + .find('input[name="path"]') + .as('newLocationInput'); + + // Get the form for the new location inside modal + cy.get('@newLocationInput') + .should('have.value', '') + .then(($input) => + cy.window().then((win) => { + const input = $input[0]; + if (input == null || !(input instanceof win.HTMLInputElement)) { + throw new Error('Input not found'); + } + + return input.form; + }) + ) + .as('newLocationForm'); + + cy.get('@newLocationForm').within(() => { + // Check if the "Open new location once added" checkbox is checked by default + cy.get('label') + .contains('Open new location once added') + .parent() + .find('input[type="checkbox"]') + .should('be.checked'); + + // Check if the "Add" button is disabled + cy.get('button').contains('Add').as('addLocationButton'); + cy.get('@addLocationButton').should('be.disabled'); + + // Check if the "Add" button is enabled after typing a valid location + cy.get('@newLocationInput').type('/'); + cy.get('@addLocationButton').should('be.enabled'); + + // Check if the "Add" button goes back to disabled after clearing the input + cy.get('@newLocationInput').clear(); + cy.get('@addLocationButton').should('be.disabled'); + + // Check if the "Add" is disabled and an error message is shown after typing an invalid location + cy.get('@newLocationInput').type('nonExisting/path/I/hope'); + cy.get('@addLocationButton').should('be.disabled'); + cy.get('div > p').contains("location not found "); + + // Get location and add it as a new location + cy.task('repoRoot').then((repoRoot) => { + cy.get('@newLocationInput').clear().type(`${repoRoot}/${locationName}`); + cy.get('@addLocationButton').click(); + }); + }); + + // Check if location was added to sidebar + cy.get('div.group').children('div:contains("Locations") + a').contains(locationName); + + // Check if location is being scanned + cy.get('button[id="job-manager-button"]').click(); + cy.get('span') + .contains('Recent Jobs') + .parent() + .parent() + .within(() => + cy + .get('p') + .invoke('text') + .should('match', new RegExp(`^(Adding|Added) location "${locationName}"$`)) + .should('exist') + ); + + // Check redirect to location root page + cy.url().should('match', locationRegex); + }); +}); diff --git a/apps/web/cypress/fixtures/location.json b/apps/web/cypress/fixtures/location.json new file mode 100644 index 000000000..7e75b1ca4 --- /dev/null +++ b/apps/web/cypress/fixtures/location.json @@ -0,0 +1,3 @@ +{ + "locationName": "test-data" +} diff --git a/apps/web/cypress/fixtures/onboarding.json b/apps/web/cypress/fixtures/onboarding.json index 3b34c1884..5d95d4a5e 100644 --- a/apps/web/cypress/fixtures/onboarding.json +++ b/apps/web/cypress/fixtures/onboarding.json @@ -1,4 +1,5 @@ { - "discord": "https://discord.gg/ukRnWSnAbG", + "discordUrl": "https://discord.gg/ukRnWSnAbG", + "privacyUrl": "https://www.spacedrive.com/docs/product/resources/privacy", "libraryName": "Test Library" } diff --git a/apps/web/cypress/fixtures/routes.ts b/apps/web/cypress/fixtures/routes.ts new file mode 100644 index 000000000..291be1624 --- /dev/null +++ b/apps/web/cypress/fixtures/routes.ts @@ -0,0 +1,8 @@ +export const onboardingRegex = /\/onboarding\/alpha$/; +export const libraryRegex = /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\//; +export const newLibraryRegex = /\/onboarding\/new-library$/; +export const onboardingLocationRegex = /\/onboarding\/locations$/; +export const onboardingPrivacyRegex = /\/onboarding\/privacy$/; +export const librarySettingsRegex = /\/settings\/library\/general$/; +export const onboardingLibraryRegex = /\/onboarding\/new-library$/; +export const locationRegex = /\/location\/1$/; diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts index 95857aea4..3ba3175d7 100644 --- a/apps/web/cypress/support/commands.ts +++ b/apps/web/cypress/support/commands.ts @@ -1,37 +1,97 @@ /// -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// -// declare global { -// namespace Cypress { -// interface Chainable { -// login(email: string, password: string): Chainable -// drag(subject: string, options?: Partial): Chainable -// dismiss(subject: string, options?: Partial): Chainable -// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable -// } -// } -// } + +import { + libraryRegex, + librarySettingsRegex, + onboardingLibraryRegex, + onboardingLocationRegex, + onboardingPrivacyRegex, + onboardingRegex +} from '../fixtures/routes'; + +declare global { + namespace Cypress { + interface Chainable { + deleteLibrary(libraryName: string): Chainable; + fastOnboarding(libraryName: string): Chainable; + checkUrlIsLibrary(): Chainable; + } + } +} + +Cypress.Commands.add('checkUrlIsLibrary', () => + cy.url().should((url) => expect(libraryRegex.test(url)).to.be.true) +); + +Cypress.Commands.add('fastOnboarding', (libraryName: string) => { + // Initial alpha onboarding screen + cy.visit('/'); + + // Delete previous library if it exists + cy.url() + .should('match', new RegExp(`${libraryRegex.source}|${onboardingRegex.source}`)) + .then((url) => (onboardingRegex.test(url) ? url : cy.deleteLibrary(libraryName))); + + cy.get('a').contains('Continue').should('have.attr', 'href', '/onboarding/new-library').click(); + + // Library name screen + cy.url().should('match', onboardingLibraryRegex); + cy.get('input[placeholder="e.g. \\"James\' Library\\""]').type(libraryName); + cy.get('button').contains('New library').click(); + + // Default locations screen + cy.url().should('match', onboardingLocationRegex); + cy.get('button').contains('Continue').click(); + + // Privacy screen + cy.url().should('match', onboardingPrivacyRegex); + cy.get('button[type="submit"]').contains('Continue').click(); + + // Check redirect to Library + cy.checkUrlIsLibrary(); +}); + +Cypress.Commands.add('deleteLibrary', (libraryName: string) => { + // Click on the library submenu + cy.get('button[aria-haspopup="menu"]').contains(libraryName).click(); + cy.get('a').contains('Manage Library').click(); + + // Check redirect to Library settings + cy.url().should('match', librarySettingsRegex); + + // Check Library seetings screen title + cy.get('h1').should('contain', 'Library Settings'); + + // Check Library name is correct + cy.get('label') + .contains('Name') + .parent() + .find('input') + .should((input) => { + expect(input.val()).to.eq(libraryName); + }); + + // Delete Library + cy.get('button').contains('Delete').click(); + + // Check confirmation modal for deleting appears + cy.get('body > div[role="dialog"]').as('deleteModal'); + + // Check modal title + cy.get('@deleteModal').find('h2').should('contain', 'Delete Library'); + + cy.on('uncaught:exception', (err, runnable) => { + // These errors are expected to occour right after the Library is deleted + if (err.message.includes('Attempted to do library operation with no library set')) { + return false; + } + }); + + // Confirm delete + cy.get('@deleteModal').find('button').contains('Delete').click(); + + // After deleting a library check we are redirected back to onboarding); + cy.url().should('match', onboardingRegex); +}); + +export {}; diff --git a/apps/web/cypress/tsconfig.json b/apps/web/cypress/tsconfig.json index fb4800d97..2fe3c4929 100644 --- a/apps/web/cypress/tsconfig.json +++ b/apps/web/cypress/tsconfig.json @@ -3,7 +3,7 @@ "target": "es5", "lib": ["es5", "dom"], "types": ["cypress", "node"], - "resolveJsonModule": true, + "resolveJsonModule": true }, "include": ["**/*.ts"] } diff --git a/apps/web/package.json b/apps/web/package.json index 9f6501193..8da322c33 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,13 +4,14 @@ "type": "module", "scripts": { "dev:api": "env E2E_TEST=1 cargo run -p sd-server", + "dev:web": "env WAIT_ON_TIMEOUT=1800000 start-test dev:api http://localhost:8080 dev", "dev": "vite", "build": "vite build", "preview": "vite preview", "test": "VITE_SD_DEMO_MODE=true playwright test", "test:e2e": "env WAIT_ON_TIMEOUT=1800000 start-test dev:api http://localhost:8080 dev http://localhost:8002 cy:run", "test:interactive": "env WAIT_ON_TIMEOUT=1800000 start-test dev:api http://localhost:8080 dev http://localhost:8002 cy:open", - "cy:run": "env ELECTRON_EXTRA_LAUNCH_ARGS=--lang=en cypress run", + "cy:run": "cypress run --browser webkit", "cy:open": "env ELECTRON_EXTRA_LAUNCH_ARGS=--lang=en cypress open --e2e", "typecheck": "tsc -b", "lint": "eslint src --cache" @@ -35,6 +36,7 @@ "autoprefixer": "^10.4.18", "cypress": "^13.7.0", "eslint-plugin-cypress": "^2.15.1", + "playwright-webkit": "^1.42.1", "postcss": "^8.4.36", "rollup-plugin-visualizer": "^5.12.0", "start-server-and-test": "^2.0.3", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8f312d0a4..513ff850e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -5,7 +5,7 @@ "declarationDir": "dist", "types": ["vite/client"] }, - "include": ["src", "src/demoData.json"], + "include": ["src", "src/demoData.json", ".eslintrc.cjs"], "references": [ { "path": "../../interface" diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 8d651c2df..91fcd8585 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -61,24 +61,25 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => { // Can stay here until we add columns view // Once added, the provided parent related logic should move to useExplorerDroppable // that way we don't have to re-use the same logic for each view + const { parent } = explorer; const { setDroppableRef } = useExplorerDroppable({ - ...(explorer.parent?.type === 'Location' && { + ...(parent?.type === 'Location' && { allow: ['Path', 'NonIndexedPath'], - data: { type: 'location', path: path ?? '/', data: explorer.parent.location }, + data: { type: 'location', path: path ?? '/', data: parent.location }, disabled: drag?.type === 'dragging' && - explorer.parent.location.id === drag.sourceLocationId && + parent.location.id === drag.sourceLocationId && (path ?? '/') === drag.sourcePath }), - ...(explorer.parent?.type === 'Ephemeral' && { + ...(parent?.type === 'Ephemeral' && { allow: ['Path', 'NonIndexedPath'], - data: { type: 'location', path: explorer.parent.path }, - disabled: drag?.type === 'dragging' && explorer.parent.path === drag.sourcePath + data: { type: 'location', path: parent.path }, + disabled: drag?.type === 'dragging' && parent.path === drag.sourcePath }), - ...(explorer.parent?.type === 'Tag' && { + ...(parent?.type === 'Tag' && { allow: 'Path', - data: { type: 'tag', data: explorer.parent.tag }, - disabled: drag?.type === 'dragging' && explorer.parent.tag.id === drag.sourceTagId + data: { type: 'tag', data: parent.tag }, + disabled: drag?.type === 'dragging' && parent.tag.id === drag.sourceTagId }) }); diff --git a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx index 41e931e73..70300164d 100644 --- a/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/SidebarLayout/Footer.tsx @@ -65,6 +65,7 @@ export default () => { popover={jobManagerPopover} trigger={