Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/vitest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
on: pull_request

name: Unit tests

env:
COMPOSE_USER: runner

jobs:
vitest:
name: Vitest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup network
run: docker network create frontend

- name: Install dependencies
run: docker compose run --rm node npm install

- name: Run unit tests
run: docker compose run --rm node npm run test:unit
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ All notable changes to this project will be documented in this file.
- Optimized release data fetching.
- Optimized list loading.
- Removed fixture length check from test.
- Added vitest for frontend unit tests.

### NB! Prior to 3.x the project was split into separate repositories

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,26 @@ We use PHPUnit for API tests:
task test:api
```

### Unit tests - Vitest

Use Vitest for unit and component tests (pure functions, utilities, components with jsdom).

Test files are located in `assets/tests/` alongside the Playwright tests.

Vitest picks up `*.test.js` files.

Unit tests for client and admin utility functions use Vitest:

```shell
task test:unit
```

### Frontend tests - Playwright

Use Playwright for end-to-end tests that run against the full application in a real browser.

Playwright picks up `*.spec.js` files.

To test the React apps we use playwright.

#### Updating Playwright
Expand Down
5 changes: 5 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ tasks:
cmds:
- task compose -- exec phpfpm cp -r fixtures/public/fixtures public/

test:unit:
desc: "Runs client unit tests (Vitest)."
cmds:
- task compose -- run --rm node npm run test:unit {{.CLI_ARGS}}

assets:build:
desc: "Build the assets."
cmds:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { test, expect } from "@playwright/test";
import { describe, it, expect } from "vitest";
import contentString from "../../admin/components/util/helpers/content-string.jsx";

test.describe("Content string", () => {
test("It creates a string: 'test and test'", async ({ page }) => {
describe("Content string", () => {
it("creates a string: 'test and test'", () => {
expect(contentString([{ name: "test" }, { name: "test" }], "and")).toBe(
"test and test",
);
});

test("It creates a string: 'test, hest or test'", async ({ page }) => {
it("creates a string: 'test, hest or test'", () => {
expect(
contentString(
[{ name: "test" }, { label: "hest" }, { name: "test" }],
Expand All @@ -17,7 +17,7 @@ test.describe("Content string", () => {
).toBe("test, hest or test");
});

test("It creates a string: 'test'", async ({ page }) => {
it("creates a string: 'test'", () => {
expect(contentString([{ name: "test" }], "or")).toBe("test");
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { test, expect } from "@playwright/test";
import { describe, it, expect } from "vitest";
import rebuildMediaFromContent from "../../admin/components/slide/slide-media-utils";

test.describe("Slide media sync", () => {
test("It returns media IRIs referenced in content fields", () => {
describe("Slide media sync", () => {
it("returns media IRIs referenced in content fields", () => {
const content = {
mainImage: ["/v2/media/1", "/v2/media/2"],
backgroundVideo: ["/v2/media/3"],
Expand All @@ -13,7 +13,7 @@ test.describe("Slide media sync", () => {
expect(result).toEqual(["/v2/media/1", "/v2/media/2", "/v2/media/3"]);
});

test("It excludes TEMP-- IDs that have not been uploaded yet", () => {
it("excludes TEMP-- IDs that have not been uploaded yet", () => {
const content = {
mainImage: ["TEMP--abc123", "/v2/media/1"],
};
Expand All @@ -23,7 +23,7 @@ test.describe("Slide media sync", () => {
expect(result).toEqual(["/v2/media/1"]);
});

test("It removes media no longer referenced in any content field", () => {
it("removes media no longer referenced in any content field", () => {
const content = {
mainImage: ["/v2/media/2"],
};
Expand All @@ -34,7 +34,7 @@ test.describe("Slide media sync", () => {
expect(result).not.toContain("/v2/media/1");
});

test("It returns empty array when all media is removed from content", () => {
it("returns empty array when all media is removed from content", () => {
const content = {
mainImage: [],
backgroundVideo: ["/v2/media/3"],
Expand All @@ -45,7 +45,7 @@ test.describe("Slide media sync", () => {
expect(result).toEqual(["/v2/media/3"]);
});

test("It deduplicates media used across multiple content fields", () => {
it("deduplicates media used across multiple content fields", () => {
const content = {
mainImage: ["/v2/media/1"],
thumbnail: ["/v2/media/1"],
Expand All @@ -56,15 +56,15 @@ test.describe("Slide media sync", () => {
expect(result).toEqual(["/v2/media/1"]);
});

test("It handles non-existent content fields gracefully", () => {
it("handles non-existent content fields gracefully", () => {
const content = {};

const result = rebuildMediaFromContent(content);

expect(result).toEqual([]);
});

test("It handles nested content field paths", () => {
it("handles nested content field paths", () => {
const content = {
sections: {
hero: ["/v2/media/1"],
Expand All @@ -76,7 +76,7 @@ test.describe("Slide media sync", () => {
expect(result).toEqual(["/v2/media/1"]);
});

test("It ignores non-media content values when scanning top-level keys", () => {
it("ignores non-media content values when scanning top-level keys", () => {
const content = {
images: ["/v2/media/1"],
title: "Some text",
Expand All @@ -91,7 +91,7 @@ test.describe("Slide media sync", () => {
expect(result).not.toContain("news");
});

test("It does not include non-media string arrays from content", () => {
it("does not include non-media string arrays from content", () => {
const content = {
images: ["/v2/media/1"],
tags: ["news", "sports"],
Expand All @@ -104,7 +104,7 @@ test.describe("Slide media sync", () => {
expect(result).not.toContain("sports");
});

test("It avoids infinite recursion when content contains circular references", () => {
it("avoids infinite recursion when content contains circular references", () => {
const circular = { images: ["/v2/media/1"] };
circular.self = circular; // create an explicit cycle

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { describe, it, expect } from "vitest";

/**
* Regression tests for the CampaignsButton.onClick promise chain.
Expand Down Expand Up @@ -27,7 +27,7 @@ function onClick({
}) {
setLoading(true);

getAllPagesScreenGroups()
return getAllPagesScreenGroups()
.then((screenGroups) => {
const screenGroupIds = screenGroups
.filter(({ campaignsLength }) => campaignsLength > 0)
Expand Down Expand Up @@ -91,49 +91,39 @@ function createMocks({ failAt } = {}) {
};
}

test.describe("CampaignsButton onClick promise chain", () => {
test("happy path resolves and clears loading", async () => {
describe("CampaignsButton onClick promise chain", () => {
it("happy path resolves and clears loading", async () => {
const mocks = createMocks();
onClick(mocks);

await new Promise((resolve) => setTimeout(resolve, 50));
await onClick(mocks);

expect(mocks.state.loading).toBe(false);
expect(mocks.state.campaigns.length).toBeGreaterThan(0);
});

test("rejection in getAllPagesScreenGroups clears loading", async () => {
it("rejection in getAllPagesScreenGroups clears loading", async () => {
const mocks = createMocks({ failAt: "screenGroups" });
onClick(mocks);

await new Promise((resolve) => setTimeout(resolve, 50));
await onClick(mocks);

expect(mocks.state.loading).toBe(false);
});

test("rejection in getAllScreenGroupCampaigns clears loading", async () => {
it("rejection in getAllScreenGroupCampaigns clears loading", async () => {
const mocks = createMocks({ failAt: "screenGroupCampaigns" });
onClick(mocks);

await new Promise((resolve) => setTimeout(resolve, 50));
await onClick(mocks);

expect(mocks.state.loading).toBe(false);
});

test("rejection in getAllPagesScreenCampaigns clears loading", async () => {
it("rejection in getAllPagesScreenCampaigns clears loading", async () => {
const mocks = createMocks({ failAt: "screenCampaigns" });
onClick(mocks);

await new Promise((resolve) => setTimeout(resolve, 50));
await onClick(mocks);

expect(mocks.state.loading).toBe(false);
});

test("rejection in getAllCampaigns clears loading", async () => {
it("rejection in getAllCampaigns clears loading", async () => {
const mocks = createMocks({ failAt: "allCampaigns" });
onClick(mocks);

await new Promise((resolve) => setTimeout(resolve, 50));
await onClick(mocks);

expect(mocks.state.loading).toBe(false);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { describe, it, expect } from "vitest";
import getAllPages from "../../admin/components/util/helpers/get-all-pages.js";

function createHydraResponse(members, hasNext = false) {
Expand All @@ -25,8 +25,8 @@ function createMockDispatch(responses) {
return fn;
}

test.describe("getAllPages", () => {
test("It returns results from a single page", async () => {
describe("getAllPages", () => {
it("returns results from a single page", async () => {
const dispatch = createMockDispatch([
createHydraResponse([{ id: 1 }, { id: 2 }]),
]);
Expand All @@ -37,7 +37,7 @@ test.describe("getAllPages", () => {
expect(dispatch.getCallCount()).toBe(1);
});

test("It fetches multiple pages when hydra:next is present", async () => {
it("fetches multiple pages when hydra:next is present", async () => {
const dispatch = createMockDispatch([
createHydraResponse([{ id: 1 }], true),
createHydraResponse([{ id: 2 }], true),
Expand All @@ -50,7 +50,7 @@ test.describe("getAllPages", () => {
expect(dispatch.getCallCount()).toBe(3);
});

test("It passes page number and params to endpoint", async () => {
it("passes page number and params to endpoint", async () => {
const calls = [];
const endpoint = {
initiate: (params) => {
Expand All @@ -71,7 +71,7 @@ test.describe("getAllPages", () => {
]);
});

test("It stops when hydra:view is null", async () => {
it("stops when hydra:view is null", async () => {
const dispatch = createMockDispatch([
{
data: {
Expand All @@ -87,7 +87,7 @@ test.describe("getAllPages", () => {
expect(dispatch.getCallCount()).toBe(1);
});

test("It stops when a page returns empty results", async () => {
it("stops when a page returns empty results", async () => {
const dispatch = createMockDispatch([
createHydraResponse([{ id: 1 }], true),
createHydraResponse([], true),
Expand All @@ -99,7 +99,7 @@ test.describe("getAllPages", () => {
expect(dispatch.getCallCount()).toBe(2);
});

test("It respects the max pages limit", async () => {
it("respects the max pages limit", async () => {
const responses = Array.from({ length: 101 }, (_, i) =>
createHydraResponse([{ id: i }], true),
);
Expand All @@ -111,15 +111,15 @@ test.describe("getAllPages", () => {
expect(dispatch.getCallCount()).toBe(100);
});

test("It propagates fetch errors", async () => {
it("propagates fetch errors", async () => {
const dispatch = () => Promise.reject(new Error("Network error"));

await expect(
getAllPages(dispatch, createMockEndpoint(), {}),
).rejects.toThrow("Network error");
});

test("It returns empty array when first page has no results", async () => {
it("returns empty array when first page has no results", async () => {
const dispatch = createMockDispatch([createHydraResponse([])]);

const result = await getAllPages(dispatch, createMockEndpoint(), {});
Expand Down
38 changes: 38 additions & 0 deletions assets/tests/client/id-from-path.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import idFromPath from "../../client/util/id-from-path";

describe("idFromPath", () => {
it("extracts a ULID from a path", () => {
expect(idFromPath("/v2/screens/01ARZ3NDEKTSV4RRFFQ69G5FAV")).toBe(
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
);
});

it("returns the first 26-char match when multiple exist", () => {
expect(
idFromPath(
"/v2/screens/01ARZ3NDEKTSV4RRFFQ69G5FAV/playlists/01BRZ3NDEKTSV4RRFFQ69G5FAV",
),
).toBe("01ARZ3NDEKTSV4RRFFQ69G5FAV");
});

it("returns false for a string with no 26-char alphanumeric match", () => {
expect(idFromPath("/v2/screens/short")).toBe(false);
});

it("returns false for an empty string", () => {
expect(idFromPath("")).toBe(false);
});

it("returns false for null", () => {
expect(idFromPath(null)).toBe(false);
});

it("returns false for undefined", () => {
expect(idFromPath(undefined)).toBe(false);
});

it("returns false for a number", () => {
expect(idFromPath(123)).toBe(false);
});
});
1 change: 1 addition & 0 deletions assets/tests/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
Loading
Loading