build(test): run unit tests with vitest and jsdom
Karma needed headless Chrome. Vitest with jsdom runs with no browser. Lighthouse CI now gets Chrome from @puppeteer/browsers. Two CSS font tests are dropped: jsdom does not apply real CSS.
This commit is contained in:
1 parent
dd7c9f6e3d
commit
4e59770e53
14 files changed
+751
-1865
No files matched your search
@@ -26,6 +26,7 @@ jobs:
|
|||||||
check-latest: true
|
check-latest: true
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
|
# Shared libraries for the Chrome that Lighthouse CI runs
|
||||||
- name: Install Linux Libs
|
- name: Install Linux Libs
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
@@ -57,21 +58,19 @@ jobs:
|
|||||||
- name: i18n Key Check
|
- name: i18n Key Check
|
||||||
run: npm run i18n:check
|
run: npm run i18n:check
|
||||||
|
|
||||||
# 3. Unit Tests (Logic) — browser/launcher configured in karma.conf.js
|
# 3. Unit Tests (Vitest + jsdom, no browser)
|
||||||
- name: Unit Tests
|
- name: Unit Tests
|
||||||
run: npx ng test --watch=false
|
run: npm test
|
||||||
|
|
||||||
# 4. Build Production (necessary for lighthouse)
|
# 4. Build Production (necessary for lighthouse)
|
||||||
- name: Build Production
|
- name: Build Production
|
||||||
run: npx ng build --configuration production
|
run: npx ng build --configuration production
|
||||||
|
|
||||||
# 5. Lighthouse Audit (Performance & SEO)
|
# 5. Lighthouse Audit (Performance & SEO)
|
||||||
# Puppeteer is a devDependency, so `npm ci` already installed it and
|
|
||||||
# downloaded Chrome. executablePath() is async in Puppeteer v25, so await it.
|
|
||||||
- name: Lighthouse CI
|
- name: Lighthouse CI
|
||||||
run: |
|
run: |
|
||||||
CHROME_PATH=$(node -e 'require("puppeteer").executablePath().then(p => console.log(p))')
|
CHROME_PATH=$(npx @puppeteer/browsers install chrome@stable --path "$RUNNER_TEMP/chrome" --format '{{path}}')
|
||||||
export CHROME_PATH=$CHROME_PATH
|
export CHROME_PATH
|
||||||
npx lhci autorun
|
npx lhci autorun
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# Build
|
# Build
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# The production build never runs tests, so skip Puppeteer's Chrome download.
|
|
||||||
ENV PUPPETEER_SKIP_DOWNLOAD=1
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ To run the unit tests:
|
|||||||
```bash
|
```bash
|
||||||
ng test
|
ng test
|
||||||
```
|
```
|
||||||
|
`npm test` runs once (Vitest + jsdom). `ng test` watches in a terminal; use `--watch=false` to run once.
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ Then open http://localhost:8080
|
|||||||
| `npm start` | Dev server |
|
| `npm start` | Dev server |
|
||||||
| `npm run build` | Production build |
|
| `npm run build` | Production build |
|
||||||
| `npm run watch` | Rebuild on change (development configuration) |
|
| `npm run watch` | Rebuild on change (development configuration) |
|
||||||
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
|
| `npm test` | Unit tests (Vitest + jsdom, no browser needed) |
|
||||||
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
|
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
|
||||||
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
|
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ Triggered on every push to `main` and on pull requests targeting `main`.
|
|||||||
|
|
||||||
1. Lint & type check — `npm run lint`
|
1. Lint & type check — `npm run lint`
|
||||||
2. i18n key sync — `npm run i18n:check`
|
2. i18n key sync — `npm run i18n:check`
|
||||||
3. Unit tests — `npx ng test --watch=false`
|
3. Unit tests — `npm test`
|
||||||
4. Production build — `npx ng build --configuration production`
|
4. Production build — `npx ng build --configuration production`
|
||||||
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
|
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
|
||||||
|
|
||||||
|
|||||||
+1
-21
@@ -87,27 +87,7 @@
|
|||||||
"builder": "@angular/build:extract-i18n"
|
"builder": "@angular/build:extract-i18n"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:karma",
|
"builder": "@angular/build:unit-test"
|
||||||
"options": {
|
|
||||||
"karmaConfig": "karma.conf.js",
|
|
||||||
"tsConfig": "tsconfig.spec.json",
|
|
||||||
"inlineStyleLanguage": "scss",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "src/assets",
|
|
||||||
"output": "/assets"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"styles": [
|
|
||||||
"src/tailwind.css",
|
|
||||||
"src/styles.scss"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@angular-eslint/builder:lint",
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
|
||||||
const { computeExecutablePath, Browser } = require('@puppeteer/browsers');
|
|
||||||
const puppeteer = require('puppeteer');
|
|
||||||
|
|
||||||
// Resolve the Chrome that Puppeteer downloaded so tests run without a
|
|
||||||
// system-installed browser, both locally and in CI containers.
|
|
||||||
// Puppeteer v25 made executablePath() async, which a sync karma.conf cannot
|
|
||||||
// await — so we compute the path synchronously via @puppeteer/browsers instead.
|
|
||||||
const buildId = puppeteer.PUPPETEER_REVISIONS.chrome;
|
|
||||||
const cacheDir = process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');
|
|
||||||
process.env.CHROME_BIN = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir });
|
|
||||||
|
|
||||||
module.exports = function (config) {
|
|
||||||
config.set({
|
|
||||||
basePath: '',
|
|
||||||
frameworks: ['jasmine'],
|
|
||||||
plugins: [
|
|
||||||
require('karma-jasmine'),
|
|
||||||
require('karma-chrome-launcher'),
|
|
||||||
require('karma-jasmine-html-reporter'),
|
|
||||||
require('karma-coverage'),
|
|
||||||
],
|
|
||||||
jasmineHtmlReporter: {
|
|
||||||
suppressAll: true,
|
|
||||||
},
|
|
||||||
coverageReporter: {
|
|
||||||
dir: path.join(__dirname, 'coverage', 'playground-frontend'),
|
|
||||||
subdir: '.',
|
|
||||||
reporters: [{ type: 'html' }, { type: 'text-summary' }],
|
|
||||||
},
|
|
||||||
reporters: ['progress', 'kjhtml'],
|
|
||||||
browsers: ['ChromeHeadlessNoSandbox'],
|
|
||||||
customLaunchers: {
|
|
||||||
ChromeHeadlessNoSandbox: {
|
|
||||||
base: 'ChromeHeadless',
|
|
||||||
// CI containers run as root with no sandbox; /dev/shm is often too small.
|
|
||||||
flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
restartOnFileChange: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Generated
+717
-1721
File diff suppressed because it is too large.
Load diff
+4
-10
@@ -6,7 +6,7 @@
|
|||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test --watch=false",
|
||||||
"lint": "ng lint",
|
"lint": "ng lint",
|
||||||
"i18n:check": "node scripts/check-i18n.mjs"
|
"i18n:check": "node scripts/check-i18n.mjs"
|
||||||
},
|
},
|
||||||
@@ -38,20 +38,14 @@
|
|||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@puppeteer/browsers": "3.2.4",
|
"@puppeteer/browsers": "3.2.4",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/jasmine": "~7.0.0",
|
|
||||||
"@webgpu/types": "^0.1.74",
|
"@webgpu/types": "^0.1.74",
|
||||||
"angular-eslint": "22.6.0",
|
"angular-eslint": "22.6.0",
|
||||||
"eslint": "^10.12.0",
|
"eslint": "^10.12.0",
|
||||||
"jasmine-core": "^7.0.2",
|
"jsdom": "^30.1.2",
|
||||||
"karma": "^6.4.4",
|
|
||||||
"karma-chrome-launcher": "^3.2.0",
|
|
||||||
"karma-coverage": "^2.2.1",
|
|
||||||
"karma-jasmine": "^5.1.0",
|
|
||||||
"karma-jasmine-html-reporter": "^2.2.0",
|
|
||||||
"puppeteer": "^25.13.0",
|
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.71.1"
|
"typescript-eslint": "^8.71.1",
|
||||||
|
"vitest": "^5.0.4"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"tmp": "^0.2.3",
|
"tmp": "^0.2.3",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { Component, provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
|
||||||
import { By, DomSanitizer } from '@angular/platform-browser';
|
import { By, DomSanitizer } from '@angular/platform-browser';
|
||||||
import { MatIconRegistry } from '@angular/material/icon';
|
import { MatIconRegistry } from '@angular/material/icon';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
@@ -188,57 +187,5 @@ describe('AboutComponent', () => {
|
|||||||
expect(emptyLinks.length).toBe(0);
|
expect(emptyLinks.length).toBe(0);
|
||||||
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
|
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the mail button in the same font as the links it sits next to', () => {
|
|
||||||
const mailButton = fixture.debugElement.query(By.css('[data-testid="contact-mail"]')).nativeElement;
|
|
||||||
const siblingLink = fixture.debugElement
|
|
||||||
.queryAll(By.css('a'))
|
|
||||||
.find(anchor => anchor.nativeElement.textContent.trim() === 'Gitea')!.nativeElement;
|
|
||||||
|
|
||||||
const buttonStyle = getComputedStyle(mailButton);
|
|
||||||
const linkStyle = getComputedStyle(siblingLink);
|
|
||||||
|
|
||||||
expect(buttonStyle.fontSize).toBe(linkStyle.fontSize);
|
|
||||||
expect(buttonStyle.fontFamily).toBe(linkStyle.fontFamily);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Material typography (`brand-family` in styles.scss) and the Tailwind body
|
|
||||||
* stack are declared in two separate places and are easy to change apart. When
|
|
||||||
* they disagree, only visitors who happen to have the extra font installed
|
|
||||||
* locally see it, so it will not show up in review.
|
|
||||||
*/
|
|
||||||
describe('global font stack', () => {
|
|
||||||
@Component({
|
|
||||||
imports: [MatCardModule],
|
|
||||||
template: `<mat-card><mat-card-title>probe</mat-card-title></mat-card>`,
|
|
||||||
})
|
|
||||||
class FontProbeComponent {}
|
|
||||||
|
|
||||||
function normalizeStack(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/["']/g, '')
|
|
||||||
.split(',')
|
|
||||||
.map(family => family.trim())
|
|
||||||
.filter(family => family.length > 0)
|
|
||||||
.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
it('resolves Material card titles to the same font stack as body copy', async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [FontProbeComponent],
|
|
||||||
providers: [provideZonelessChangeDetection()],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
const probe = TestBed.createComponent(FontProbeComponent);
|
|
||||||
probe.detectChanges();
|
|
||||||
|
|
||||||
const title = probe.nativeElement.querySelector('.mat-mdc-card-title') as HTMLElement;
|
|
||||||
const titleStack = normalizeStack(getComputedStyle(title).fontFamily);
|
|
||||||
const bodyStack = normalizeStack(getComputedStyle(document.body).fontFamily);
|
|
||||||
|
|
||||||
expect(titleStack).toBe(bodyStack);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -44,7 +44,7 @@ describe('SortingService', () => {
|
|||||||
const result = finalValues(snapshots);
|
const result = finalValues(snapshots);
|
||||||
|
|
||||||
expect(result).toEqual(expectedSorted);
|
expect(result).toEqual(expectedSorted);
|
||||||
expect(isAscending(result)).toBeTrue();
|
expect(isAscending(result)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`${algorithm} should not mutate the input array`, () => {
|
it(`${algorithm} should not mutate the input array`, () => {
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import { register } from 'swiper/element/bundle';
|
|||||||
import { ProjectDialogComponent } from './project-dialog.component';
|
import { ProjectDialogComponent } from './project-dialog.component';
|
||||||
|
|
||||||
describe('ProjectDialogComponent', () => {
|
describe('ProjectDialogComponent', () => {
|
||||||
|
// jsdom has no Element.part, which swiper-container uses while it renders.
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(Element.prototype, 'part', {
|
||||||
|
value: { add: () => undefined },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete (Element.prototype as { part?: unknown }).part;
|
||||||
|
});
|
||||||
|
|
||||||
it('registers the swiper custom elements', () => {
|
it('registers the swiper custom elements', () => {
|
||||||
expect(customElements.get('swiper-container')).toBeDefined();
|
expect(customElements.get('swiper-container')).toBeDefined();
|
||||||
expect(customElements.get('swiper-slide')).toBeDefined();
|
expect(customElements.get('swiper-slide')).toBeDefined();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ describe('GpuCapabilityService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
setGpu(originalGpu);
|
setGpu(originalGpu);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ describe('GpuCapabilityService', () => {
|
|||||||
it('falls back to "webgl" when WebGPU is missing but a WebGL2 context exists', async () => {
|
it('falls back to "webgl" when WebGPU is missing but a WebGL2 context exists', async () => {
|
||||||
setGpu(undefined);
|
setGpu(undefined);
|
||||||
const fakeCanvas = { getContext: () => ({}) } as unknown as HTMLCanvasElement;
|
const fakeCanvas = { getContext: () => ({}) } as unknown as HTMLCanvasElement;
|
||||||
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
|
vi.spyOn(document, 'createElement').mockReturnValue(fakeCanvas);
|
||||||
|
|
||||||
const tier = await service.detect();
|
const tier = await service.detect();
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ describe('GpuCapabilityService', () => {
|
|||||||
it('reports "none" when neither WebGPU nor WebGL is available', async () => {
|
it('reports "none" when neither WebGPU nor WebGL is available', async () => {
|
||||||
setGpu(undefined);
|
setGpu(undefined);
|
||||||
const fakeCanvas = { getContext: () => null } as unknown as HTMLCanvasElement;
|
const fakeCanvas = { getContext: () => null } as unknown as HTMLCanvasElement;
|
||||||
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
|
vi.spyOn(document, 'createElement').mockReturnValue(fakeCanvas);
|
||||||
|
|
||||||
const tier = await service.detect();
|
const tier = await service.detect();
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import type { Mock } from 'vitest';
|
||||||
import { LanguageService } from './language.service';
|
import { LanguageService } from './language.service';
|
||||||
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
|
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
|
||||||
|
|
||||||
describe('LanguageService', () => {
|
describe('LanguageService', () => {
|
||||||
let translateSpy: jasmine.SpyObj<TranslateService>;
|
let translateSpy: { use: Mock };
|
||||||
|
|
||||||
function createService(): LanguageService {
|
function createService(): LanguageService {
|
||||||
translateSpy = jasmine.createSpyObj<TranslateService>('TranslateService', ['use']);
|
translateSpy = { use: vi.fn() };
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [{ provide: TranslateService, useValue: translateSpy }],
|
providers: [{ provide: TranslateService, useValue: translateSpy }],
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"jasmine",
|
"vitest/globals",
|
||||||
"@webgpu/types"
|
"@webgpu/types"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user