# cypress/local

A Cypress suite that runs against a _local_ Songwhip stack.

- Full control over how `songwhip-api` responds with the ability to mock sub-requests (eg. Orchard API).
- We avoid flakey tests by resulting from network latency and inconsistent cache invalidation speeds.

We still have a Cypress suite for running against _real_ deployed Songwhip stacks. We regard these tests as 'smoke tests' and should only really account for 10-20% of our overall coverage. The Cypress 'local' tests are faster, less flakey and offer granular mocking control over `songwhip-api` responses.

## How it works

1. Pulls in a `songwhip-api` image from private Github Container Registry
2. Spins up local `songwhip-api` container (`localhost:5004`) with a local `postgres` database
3. Starts a local `songwhip-web` instance (`localhost:3001`)
4. Runs `cypress/local` tests against `http://localhost:3001`

## Prerequisites

- Install project dependencies using `yarn`
- Install `docker` on your machine
- Login to Github Container Registry `docker login ghcr.io --username YOUR_GITHUB_USERNAME --password A_PERSONAL_GITHUB_TOKEN` ([more info](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry))

## Run tests during dev

Runs `songwhip-web` in dev mode with a dedicated `songwhip-api` instance. Handy when you want to write tests while you develop.

```
yarn cypress:dev
```

## Point cypress at a different local songwhip-api instance

It's sometimes handy to be able to use a different songwhip-api instance where you can run see log output and set debugger breakpoints. Head over to your local songwhip-api repo and start a local database and local app.

```bash
yarn db:start
yarn dev
```

Then run cypress/local using the `--api-endpoint` option to point at the local instance.

```
yarn cypress:dev --api-endpoint http://localhost:5002/v3/
```

Now run tests and you should see log output from your songwhip-api process.

## Run all tests

Builds and starts `songwhip-web` with a dedicated `songwhip-api` instance. Tests are run in the background using a headless Chrome instance.

```
yarn test:cypress:local:run
```

## Parallel execution in Github Actions

We are using a matrix strategy to parallelize our Cypress tests in Github Actions. This is done using a 3rd party cypress plugin [cypress-split](https://github.com/bahmutov/cypress-split).

### Using "timings.json" to distribute specs across tasks

To optimize the distribution of specs run across the GA matrix tasks, we use a "test/cypress/local/timings.json" file. The file specifies the run time for each spec and is provided to the `cypress-split` plugin during execution.

`cypress-split` uses it to distribute each spec evenly into each task so the total task run time is about the same.

### Updating "timings.json"

Currently this file is manually maintained and committed to the repo. When the tests runs in Github you will see the `cypress-split` plugin outputting the timings json at the end. Copy it for each matrix task and update the `timings.json` with the new values. Then commit it.

**Note that we should only need to update the file when we see the matrix task run times starting to get uneven.**

## Best Practices

### 1. Wait for App Hydration

Our custom `cy.visit()` command automatically waits for app hydration on initial page loads. However, there's a caveat: if you visit an internal path that triggers a redirect (e.g., visiting `/artist/123` which redirects to `/artist/artist-name`), the hydration check will fail, so use full url.

For page reloads or after redirects, use `cy.waitForHydration()`:

```ts
// Good: After manual reload
cy.reload();
cy.waitForHydration();
cy.testId('myButton').click();

// Bad: Not waiting after redirect
cy.reload();
cy.testId('myButton').click(); // May fail if React hasn't hydrated after redirect
```

The app sets `data-hydrated` attribute on `<body>` after hydration completes.

### 2. Always Mock Apple Music Requests

Apple Music's MusicKit script can cause caching issues between tests. For any test spec that loads and waits for Apple Music requests, you must mock these requests in a `beforeEach` hook to ensure every test gets fresh mocks:

```ts
// Good: Mock in beforeEach for every test
describe('feature with apple music', () => {
  beforeEach(() => {
    mockAppleMusicRequests();
  });

  it('test 1', () => {
    page.visit();
    // Apple Music mocks are available
  });

  it('test 2', () => {
    page.visit();
    // Fresh Apple Music mocks are available
  });
});

// Bad: Mock only once - causes caching issues
describe('feature with apple music', () => {
  before(() => {
    mockAppleMusicRequests(); // Only mocked for first test
  });

  it('test 1', () => {
    page.visit();
    // Works
  });

  it('test 2', () => {
    page.visit();
    // Cache Music Kit sdk request
  });

  it('test 3', () => {
    page.visit();
    // May fail due to cached MusicKit script
  });
});
```

### 3. Confirm Dialog State Changes

After opening or closing dialogs, verify the state change completed:

```ts
// Good: Verify dialog opened
cy.testId('openDialog').click();
cy.testId('myDialog').should('be.visible');

// Good: Verify dialog closed
cy.testId('closeDialog').click();
cy.testId('myDialog').should('not.exist');

// Bad: Continue without verification
cy.testId('closeDialog').click();
cy.testId('nextAction').click(); // Dialog might still be closing
```

### 6. Use changeTextInput Helper for Input Changes

Use the `changeTextInput` helper instead of `.type()` for more reliable input handling (it waits for debounce):

```ts
// Good: Use helper function
changeTextInput('emailInput', 'user@email.com');

// Bad: Direct .type() may not handle clearing/debouncing properly
cy.testId('emailInput').type('user@email.com');
```

### 4. Verify URL Changes When Expected

When navigation should occur, verify the URL changed:

```ts
// Good: Verify URL changed
catalogPage.search('Drake');
cy.url().should('contain', 'search=Drake');

// Bad: Assuming navigation happened
catalogPage.search('Drake');
// Next action assumes URL changed
```

### 5. Avoid Hardcoded Waits

Replace `cy.wait(300)` with explicit assertions:

```ts
// Good: Wait for condition - modal closes
cy.testId('editCarouselItemDialog').should('not.exist');

// Bad: Hardcoded wait
cy.wait(300); // Hope dialog closed by now
```

### 6. Structure Tests to Avoid Dependencies

Individual tests should never rely on the state or side effects from previous `it()` blocks. Each test should be independently runnable:

```ts
// Good: Independent tests - each opens its own modal
describe('settings modal', () => {
  before(() => {
    cy.visit('/artist/the-drums');
  });

  it('opens modal and edits title', () => {
    cy.testId('openSettingsButton').click();
    cy.testId('settingsModal').should('be.visible');

    changeTextInput('titleInput', 'New Title');
    cy.testId('saveButton').click();
    cy.testId('settingsModal').should('not.exist');
  });

  it('opens modal and edits description', () => {
    // Opens the modal fresh for this test
    cy.testId('openSettingsButton').click();
    cy.testId('settingsModal').should('be.visible');

    changeTextInput('descriptionInput', 'New Description');
    cy.testId('saveButton').click();
    cy.testId('settingsModal').should('not.exist');
  });
});

// Bad: Tests depend on modal state from previous test
describe('settings modal', () => {
  before(() => {
    cy.visit('/artist/the-drums');
    cy.testId('openSettingsButton').click();
    cy.testId('settingsModal').should('be.visible');
  });

  it('edits title', () => {
    // Assumes modal is already open from before() hook
    changeTextInput('titleInput', 'New Title');
    cy.testId('saveButton').click();
  });

  it('edits description', () => {
    // Assumes modal is still open from previous test
    // Will fail because previous test closed the modal
    changeTextInput('descriptionInput', 'New Description');
    cy.testId('saveButton').click();
  });
});
```
