Skip to content

Commit 3bc4899

Browse files
authored
Merge pull request #387 from os2display/feature/js-unittests
Added vitest for frontend unit tests
2 parents 85fa28f + 662ba73 commit 3bc4899

15 files changed

Lines changed: 1364 additions & 143 deletions

.github/workflows/vitest.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
on: pull_request
2+
3+
name: Unit tests
4+
5+
env:
6+
COMPOSE_USER: runner
7+
8+
jobs:
9+
vitest:
10+
name: Vitest
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Setup network
17+
run: docker network create frontend
18+
19+
- name: Install dependencies
20+
run: docker compose run --rm node npm install
21+
22+
- name: Run unit tests
23+
run: docker compose run --rm node npm run test:unit

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ All notable changes to this project will be documented in this file.
3434
- Optimized release data fetching.
3535
- Optimized list loading.
3636
- Removed fixture length check from test.
37+
- Added vitest for frontend unit tests.
3738

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

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,26 @@ We use PHPUnit for API tests:
304304
task test:api
305305
```
306306

307+
### Unit tests - Vitest
308+
309+
Use Vitest for unit and component tests (pure functions, utilities, components with jsdom).
310+
311+
Test files are located in `assets/tests/` alongside the Playwright tests.
312+
313+
Vitest picks up `*.test.js` files.
314+
315+
Unit tests for client and admin utility functions use Vitest:
316+
317+
```shell
318+
task test:unit
319+
```
320+
307321
### Frontend tests - Playwright
308322

323+
Use Playwright for end-to-end tests that run against the full application in a real browser.
324+
325+
Playwright picks up `*.spec.js` files.
326+
309327
To test the React apps we use playwright.
310328

311329
#### Updating Playwright

Taskfile.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,11 @@ tasks:
229229
cmds:
230230
- task compose -- exec phpfpm cp -r fixtures/public/fixtures public/
231231

232+
test:unit:
233+
desc: "Runs client unit tests (Vitest)."
234+
cmds:
235+
- task compose -- run --rm node npm run test:unit {{.CLI_ARGS}}
236+
232237
assets:build:
233238
desc: "Build the assets."
234239
cmds:
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { test, expect } from "@playwright/test";
1+
import { describe, it, expect } from "vitest";
22
import contentString from "../../admin/components/util/helpers/content-string.jsx";
33

4-
test.describe("Content string", () => {
5-
test("It creates a string: 'test and test'", async ({ page }) => {
4+
describe("Content string", () => {
5+
it("creates a string: 'test and test'", () => {
66
expect(contentString([{ name: "test" }, { name: "test" }], "and")).toBe(
77
"test and test",
88
);
99
});
1010

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

20-
test("It creates a string: 'test'", async ({ page }) => {
20+
it("creates a string: 'test'", () => {
2121
expect(contentString([{ name: "test" }], "or")).toBe("test");
2222
});
2323
});

assets/tests/admin/admin-slide-media-sync.spec.js renamed to assets/tests/admin/admin-slide-media-sync.test.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { test, expect } from "@playwright/test";
1+
import { describe, it, expect } from "vitest";
22
import rebuildMediaFromContent from "../../admin/components/slide/slide-media-utils";
33

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

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

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

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

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

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

6262
const result = rebuildMediaFromContent(content);
6363

6464
expect(result).toEqual([]);
6565
});
6666

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

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

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

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

assets/tests/admin/campaigns-button-promise-chain.spec.js renamed to assets/tests/admin/campaigns-button-promise-chain.test.js

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from "@playwright/test";
1+
import { describe, it, expect } from "vitest";
22

33
/**
44
* Regression tests for the CampaignsButton.onClick promise chain.
@@ -27,7 +27,7 @@ function onClick({
2727
}) {
2828
setLoading(true);
2929

30-
getAllPagesScreenGroups()
30+
return getAllPagesScreenGroups()
3131
.then((screenGroups) => {
3232
const screenGroupIds = screenGroups
3333
.filter(({ campaignsLength }) => campaignsLength > 0)
@@ -91,49 +91,39 @@ function createMocks({ failAt } = {}) {
9191
};
9292
}
9393

94-
test.describe("CampaignsButton onClick promise chain", () => {
95-
test("happy path resolves and clears loading", async () => {
94+
describe("CampaignsButton onClick promise chain", () => {
95+
it("happy path resolves and clears loading", async () => {
9696
const mocks = createMocks();
97-
onClick(mocks);
98-
99-
await new Promise((resolve) => setTimeout(resolve, 50));
97+
await onClick(mocks);
10098

10199
expect(mocks.state.loading).toBe(false);
102100
expect(mocks.state.campaigns.length).toBeGreaterThan(0);
103101
});
104102

105-
test("rejection in getAllPagesScreenGroups clears loading", async () => {
103+
it("rejection in getAllPagesScreenGroups clears loading", async () => {
106104
const mocks = createMocks({ failAt: "screenGroups" });
107-
onClick(mocks);
108-
109-
await new Promise((resolve) => setTimeout(resolve, 50));
105+
await onClick(mocks);
110106

111107
expect(mocks.state.loading).toBe(false);
112108
});
113109

114-
test("rejection in getAllScreenGroupCampaigns clears loading", async () => {
110+
it("rejection in getAllScreenGroupCampaigns clears loading", async () => {
115111
const mocks = createMocks({ failAt: "screenGroupCampaigns" });
116-
onClick(mocks);
117-
118-
await new Promise((resolve) => setTimeout(resolve, 50));
112+
await onClick(mocks);
119113

120114
expect(mocks.state.loading).toBe(false);
121115
});
122116

123-
test("rejection in getAllPagesScreenCampaigns clears loading", async () => {
117+
it("rejection in getAllPagesScreenCampaigns clears loading", async () => {
124118
const mocks = createMocks({ failAt: "screenCampaigns" });
125-
onClick(mocks);
126-
127-
await new Promise((resolve) => setTimeout(resolve, 50));
119+
await onClick(mocks);
128120

129121
expect(mocks.state.loading).toBe(false);
130122
});
131123

132-
test("rejection in getAllCampaigns clears loading", async () => {
124+
it("rejection in getAllCampaigns clears loading", async () => {
133125
const mocks = createMocks({ failAt: "allCampaigns" });
134-
onClick(mocks);
135-
136-
await new Promise((resolve) => setTimeout(resolve, 50));
126+
await onClick(mocks);
137127

138128
expect(mocks.state.loading).toBe(false);
139129
});
Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test, expect } from "@playwright/test";
1+
import { describe, it, expect } from "vitest";
22
import getAllPages from "../../admin/components/util/helpers/get-all-pages.js";
33

44
function createHydraResponse(members, hasNext = false) {
@@ -25,8 +25,8 @@ function createMockDispatch(responses) {
2525
return fn;
2626
}
2727

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

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

53-
test("It passes page number and params to endpoint", async () => {
53+
it("passes page number and params to endpoint", async () => {
5454
const calls = [];
5555
const endpoint = {
5656
initiate: (params) => {
@@ -71,7 +71,7 @@ test.describe("getAllPages", () => {
7171
]);
7272
});
7373

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

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

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

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

117117
await expect(
118118
getAllPages(dispatch, createMockEndpoint(), {}),
119119
).rejects.toThrow("Network error");
120120
});
121121

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

125125
const result = await getAllPages(dispatch, createMockEndpoint(), {});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, it, expect } from "vitest";
2+
import idFromPath from "../../client/util/id-from-path";
3+
4+
describe("idFromPath", () => {
5+
it("extracts a ULID from a path", () => {
6+
expect(idFromPath("/v2/screens/01ARZ3NDEKTSV4RRFFQ69G5FAV")).toBe(
7+
"01ARZ3NDEKTSV4RRFFQ69G5FAV",
8+
);
9+
});
10+
11+
it("returns the first 26-char match when multiple exist", () => {
12+
expect(
13+
idFromPath(
14+
"/v2/screens/01ARZ3NDEKTSV4RRFFQ69G5FAV/playlists/01BRZ3NDEKTSV4RRFFQ69G5FAV",
15+
),
16+
).toBe("01ARZ3NDEKTSV4RRFFQ69G5FAV");
17+
});
18+
19+
it("returns false for a string with no 26-char alphanumeric match", () => {
20+
expect(idFromPath("/v2/screens/short")).toBe(false);
21+
});
22+
23+
it("returns false for an empty string", () => {
24+
expect(idFromPath("")).toBe(false);
25+
});
26+
27+
it("returns false for null", () => {
28+
expect(idFromPath(null)).toBe(false);
29+
});
30+
31+
it("returns false for undefined", () => {
32+
expect(idFromPath(undefined)).toBe(false);
33+
});
34+
35+
it("returns false for a number", () => {
36+
expect(idFromPath(123)).toBe(false);
37+
});
38+
});

assets/tests/setup.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import "@testing-library/jest-dom/vitest";

0 commit comments

Comments
 (0)