From fa3ffe9e76c4e89cecc32f7c7a5d60a12a862e4d Mon Sep 17 00:00:00 2001 From: Jakub Andrysek Date: Sat, 16 May 2026 12:07:47 +0200 Subject: [PATCH 1/7] test: add 7 Mocha test suites (58 cases) for pure logic services ConsoleBusService (8): append, subscribe, clear, max cap, channels ConsoleTelemetryService (8): snapshots, KV parsing, merging, trim KeyValueParser (10): single/multi pair, edge cases, timestamps KeyValueHistory (3): deep clone, independent mutation, empty Language (9): all extension mappings, edge cases PackageEventsService (4): notify, unsubscribe, errors, safety ProjectImportUtils (10): file extension checks, size formatting Remove demo placeholder test. Fix Playwright test with real assertions (logo link, theme toggle, heading visibility). --- .../tests/mocha/console-bus-service.test.ts | 109 ++++++++++++++++++ .../mocha/console-telemetry-service.test.ts | 72 ++++++++++++ apps/web/tests/mocha/demo.test.ts | 10 -- .../web/tests/mocha/key-value-history.test.ts | 34 ++++++ apps/web/tests/mocha/key-value-parser.test.ts | 66 +++++++++++ apps/web/tests/mocha/language.test.ts | 44 +++++++ .../mocha/package-events-service.test.ts | 73 ++++++++++++ .../tests/mocha/project-import-utils.test.ts | 63 ++++++++++ apps/web/tests/playwright/index.spec.ts | 16 +-- 9 files changed, 465 insertions(+), 22 deletions(-) create mode 100644 apps/web/tests/mocha/console-bus-service.test.ts create mode 100644 apps/web/tests/mocha/console-telemetry-service.test.ts delete mode 100644 apps/web/tests/mocha/demo.test.ts create mode 100644 apps/web/tests/mocha/key-value-history.test.ts create mode 100644 apps/web/tests/mocha/key-value-parser.test.ts create mode 100644 apps/web/tests/mocha/language.test.ts create mode 100644 apps/web/tests/mocha/package-events-service.test.ts create mode 100644 apps/web/tests/mocha/project-import-utils.test.ts diff --git a/apps/web/tests/mocha/console-bus-service.test.ts b/apps/web/tests/mocha/console-bus-service.test.ts new file mode 100644 index 00000000..19590b53 --- /dev/null +++ b/apps/web/tests/mocha/console-bus-service.test.ts @@ -0,0 +1,109 @@ +import * as chai from 'chai'; +import 'mocha'; +import { ConsoleBusService } from '../../src/console/services/console-bus-service'; + +const expect = chai.expect; + +describe('ConsoleBusService', () => { + it('appends an entry to a channel and notifies subscribers', () => { + const bus = new ConsoleBusService(); + const received: unknown[] = []; + bus.subscribe('test', (entries) => received.push(entries)); + + bus.append('test', 'out', 'Hello'); + + expect(received).to.have.lengthOf(2); + expect(received[1]).to.have.lengthOf(1); + expect((received[1] as Array<{ content: string; type: string }>)[0].content).to.equal('Hello'); + expect((received[1] as Array<{ content: string; type: string }>)[0].type).to.equal('out'); + }); + + it('subscribe callback receives the current state immediately', () => { + const bus = new ConsoleBusService(); + bus.append('ch', 'out', 'first'); + + const received: unknown[] = []; + bus.subscribe('ch', (entries) => received.push(entries)); + + expect(received).to.have.lengthOf(1); + expect(received[0]).to.have.lengthOf(1); + expect((received[0] as Array<{ content: string }>)[0].content).to.equal('first'); + }); + + it('clear empties a channel and notifies subscribers', () => { + const bus = new ConsoleBusService(); + bus.append('ch', 'out', 'one'); + + const received: unknown[] = []; + bus.subscribe('ch', (entries) => received.push(entries)); + + bus.clear('ch'); + + expect(received[1]).to.have.lengthOf(0); + }); + + it('removeLastEntry removes the final entry', () => { + const bus = new ConsoleBusService(); + bus.append('ch', 'out', 'one'); + bus.append('ch', 'out', 'two'); + bus.append('ch', 'out', 'three'); + + bus.removeLastEntry('ch'); + + const received: unknown[] = []; + bus.subscribe('ch', (entries) => received.push(entries)); + + expect(received[0]).to.have.lengthOf(2); + expect((received[0] as Array<{ content: string }>)[0].content).to.equal('one'); + expect((received[0] as Array<{ content: string }>)[1].content).to.equal('two'); + }); + + it('removeLastEntry does nothing on an empty channel', () => { + const bus = new ConsoleBusService(); + bus.removeLastEntry('ch'); + + const received: unknown[] = []; + bus.subscribe('ch', (entries) => received.push(entries)); + + expect(received[0]).to.have.lengthOf(0); + }); + + it('caps entries at maxEntriesPerChannel', () => { + const bus = new ConsoleBusService(3); + bus.append('ch', 'out', 'a'); + bus.append('ch', 'out', 'b'); + bus.append('ch', 'out', 'c'); + bus.append('ch', 'out', 'd'); + + const received: unknown[] = []; + bus.subscribe('ch', (entries) => received.push(entries)); + + expect(received[0]).to.have.lengthOf(3); + expect((received[0] as Array<{ content: string }>)[0].content).to.equal('b'); + expect((received[0] as Array<{ content: string }>)[2].content).to.equal('d'); + }); + + it('unsubscribes from channel updates', () => { + const bus = new ConsoleBusService(); + const received: unknown[] = []; + const unsubscribe = bus.subscribe('ch', (entries) => received.push(entries)); + + unsubscribe(); + bus.append('ch', 'out', 'one'); + + expect(received).to.have.lengthOf(1); + }); + + it('isolates channels from each other', () => { + const bus = new ConsoleBusService(); + const ch1: unknown[] = []; + const ch2: unknown[] = []; + bus.subscribe('ch1', (e) => ch1.push(e)); + bus.subscribe('ch2', (e) => ch2.push(e)); + + bus.append('ch1', 'out', 'hello'); + + expect(ch1).to.have.lengthOf(2); + expect(ch2).to.have.lengthOf(1); + }); +}); diff --git a/apps/web/tests/mocha/console-telemetry-service.test.ts b/apps/web/tests/mocha/console-telemetry-service.test.ts new file mode 100644 index 00000000..79770e38 --- /dev/null +++ b/apps/web/tests/mocha/console-telemetry-service.test.ts @@ -0,0 +1,72 @@ +import * as chai from 'chai'; +import 'mocha'; +import { ConsoleTelemetryService } from '../../src/console/services/console-telemetry-service'; +import type { ConsoleEntry } from '../../src/console/types/types'; + +const expect = chai.expect; + +function entry(type: ConsoleEntry['type'], content: string): ConsoleEntry { + return { timestamp: new Date(), type, content }; +} + +describe('ConsoleTelemetryService', () => { + const service = new ConsoleTelemetryService(60_000); + + it('createSnapshot returns empty state', () => { + const snapshot = service.createSnapshot(); + expect(snapshot.historyEntries).to.deep.equal({}); + expect(snapshot.latestEntries).to.deep.equal({}); + }); + + it('extractKeyValuePairs parses latest values', () => { + const result = service.extractKeyValuePairs([entry('out', 'temp: 23.5')]); + expect(result.temp).to.exist; + expect(result.temp.value).to.equal(23.5); + }); + + it('extractKeyValueHistory returns history arrays', () => { + const result = service.extractKeyValueHistory([entry('out', 'temp: 23.5; humidity: 60')]); + expect(result.temp).to.have.lengthOf(1); + expect(result.humidity).to.have.lengthOf(1); + expect(result.temp[0].value).to.equal(23.5); + expect(result.humidity[0].value).to.equal(60); + }); + + it('ignores entries that are not out or err', () => { + const result = service.extractKeyValuePairs([ + entry('in', 'temp: 23.5'), + entry('out', 'humidity: 60'), + ]); + expect(result.temp).to.not.exist; + expect(result.humidity).to.exist; + }); + + it('appendTelemetry merges with existing snapshot', () => { + const snapshot = service.extractTelemetry([entry('out', 'a: 1')]); + const merged = service.appendTelemetry(snapshot, [entry('out', 'b: 2')]); + + expect(merged.latestEntries.a.value).to.equal(1); + expect(merged.latestEntries.b.value).to.equal(2); + expect(merged.historyEntries.a).to.have.lengthOf(1); + expect(merged.historyEntries.b).to.have.lengthOf(1); + }); + + it('appendTelemetry with empty entries returns the same snapshot', () => { + const snapshot = service.extractTelemetry([entry('out', 'x: 1')]); + const result = service.appendTelemetry(snapshot, []); + expect(result).to.equal(snapshot); + }); + + it('handles multiple key-value pairs on one line', () => { + const result = service.extractKeyValuePairs([entry('out', 'x: 1; y: 2; z: 3')]); + expect(result.x.value).to.equal(1); + expect(result.y.value).to.equal(2); + expect(result.z.value).to.equal(3); + }); + + it('handles float and negative values', () => { + const result = service.extractKeyValuePairs([entry('out', 'temp: -5.5; ratio: 0.333')]); + expect(result.temp.value).to.equal(-5.5); + expect(result.ratio.value).to.equal(0.333); + }); +}); diff --git a/apps/web/tests/mocha/demo.test.ts b/apps/web/tests/mocha/demo.test.ts deleted file mode 100644 index 9654ea50..00000000 --- a/apps/web/tests/mocha/demo.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as chai from 'chai'; -import 'mocha'; - -const expect = chai.expect; - -describe('Demo', () => { - it('should pass this demo test', () => { - expect(1 + 1).to.equal(2); - }); -}); diff --git a/apps/web/tests/mocha/key-value-history.test.ts b/apps/web/tests/mocha/key-value-history.test.ts new file mode 100644 index 00000000..c7a2863d --- /dev/null +++ b/apps/web/tests/mocha/key-value-history.test.ts @@ -0,0 +1,34 @@ +import * as chai from 'chai'; +import 'mocha'; +import { cloneHistoryMap } from '../../src/console/key-value/key-value-history'; + +const expect = chai.expect; + +describe('cloneHistoryMap', () => { + it('creates a deep copy of the history map', () => { + const original = { + temp: [{ value: 23.5, timestamp: 1000 }], + humidity: [{ value: 60, timestamp: 2000 }], + }; + + const cloned = cloneHistoryMap(original); + + expect(cloned).to.deep.equal(original); + expect(cloned).to.not.equal(original); + }); + + it('does not share array references with the original', () => { + const original = { temp: [{ value: 23.5, timestamp: 1000 }] }; + const cloned = cloneHistoryMap(original); + + cloned.temp.push({ value: 24, timestamp: 2000 }); + + expect(original.temp).to.have.lengthOf(1); + expect(cloned.temp).to.have.lengthOf(2); + }); + + it('handles empty history maps', () => { + const result = cloneHistoryMap({}); + expect(result).to.deep.equal({}); + }); +}); diff --git a/apps/web/tests/mocha/key-value-parser.test.ts b/apps/web/tests/mocha/key-value-parser.test.ts new file mode 100644 index 00000000..d4ec2274 --- /dev/null +++ b/apps/web/tests/mocha/key-value-parser.test.ts @@ -0,0 +1,66 @@ +import * as chai from 'chai'; +import 'mocha'; +import { parseKeyValue } from '../../src/console/key-value/key-value-parser'; + +const expect = chai.expect; + +describe('parseKeyValue', () => { + it('parses a single key:value pair', () => { + const result = parseKeyValue('temp: 23.5'); + expect(result.temp).to.exist; + expect(result.temp.value).to.equal(23.5); + expect(result.temp.timestamp).to.be.a('number'); + }); + + it('parses multiple key:value pairs on one line', () => { + const result = parseKeyValue('temp: 23.5; humidity: 60.2'); + expect(result.temp.value).to.equal(23.5); + expect(result.humidity.value).to.equal(60.2); + }); + + it('returns empty map for empty string', () => { + const result = parseKeyValue(''); + expect(result).to.deep.equal({}); + }); + + it('ignores invalid formats', () => { + const result = parseKeyValue('hello world'); + expect(result).to.deep.equal({}); + }); + + it('parses integer values', () => { + const result = parseKeyValue('count: 42'); + expect(result.count.value).to.equal(42); + }); + + it('parses negative values', () => { + const result = parseKeyValue('offset: -10'); + expect(result.offset.value).to.equal(-10); + }); + + it('ignores keys starting with a digit', () => { + const result = parseKeyValue('123key: 10'); + expect(result['123key']).to.not.exist; + }); + + it('parses values across multiple lines', () => { + const result = parseKeyValue('temp: 23.5\nhumidity: 60.2'); + expect(result.temp.value).to.equal(23.5); + expect(result.humidity.value).to.equal(60.2); + }); + + it('skips empty lines between entries', () => { + const result = parseKeyValue('temp: 23.5\n\nhumidity: 60.2'); + expect(result.temp.value).to.equal(23.5); + expect(result.humidity.value).to.equal(60.2); + }); + + it('assigns the same timestamp to all parsed entries in a call', () => { + const before = Date.now(); + const result = parseKeyValue('a: 1; b: 2'); + const after = Date.now(); + expect(result.a.timestamp).to.be.at.least(before); + expect(result.a.timestamp).to.be.at.most(after); + expect(result.b.timestamp).to.equal(result.a.timestamp); + }); +}); diff --git a/apps/web/tests/mocha/language.test.ts b/apps/web/tests/mocha/language.test.ts new file mode 100644 index 00000000..bee80a16 --- /dev/null +++ b/apps/web/tests/mocha/language.test.ts @@ -0,0 +1,44 @@ +import * as chai from 'chai'; +import 'mocha'; +import { inferLanguageFromPath } from '../../src/editor/services/language'; + +const expect = chai.expect; + +describe('inferLanguageFromPath', () => { + it('returns typescript for .ts files', () => { + expect(inferLanguageFromPath('src/app.ts')).to.equal('typescript'); + }); + + it('returns javascript for .js files', () => { + expect(inferLanguageFromPath('dist/bundle.js')).to.equal('javascript'); + }); + + it('returns javascript for .mjs files', () => { + expect(inferLanguageFromPath('lib/utils.mjs')).to.equal('javascript'); + }); + + it('returns javascript for .cjs files', () => { + expect(inferLanguageFromPath('lib/common.cjs')).to.equal('javascript'); + }); + + it('returns json for .json files', () => { + expect(inferLanguageFromPath('package.json')).to.equal('json'); + }); + + it('returns json for .jacly files', () => { + expect(inferLanguageFromPath('workspace.jacly')).to.equal('json'); + }); + + it('returns plaintext for files without extension', () => { + expect(inferLanguageFromPath('README')).to.equal('plaintext'); + }); + + it('returns plaintext for unknown extensions', () => { + expect(inferLanguageFromPath('data.csv')).to.equal('plaintext'); + expect(inferLanguageFromPath('image.png')).to.equal('plaintext'); + }); + + it('returns plaintext for empty path', () => { + expect(inferLanguageFromPath('')).to.equal('plaintext'); + }); +}); diff --git a/apps/web/tests/mocha/package-events-service.test.ts b/apps/web/tests/mocha/package-events-service.test.ts new file mode 100644 index 00000000..06d1df7b --- /dev/null +++ b/apps/web/tests/mocha/package-events-service.test.ts @@ -0,0 +1,73 @@ +import * as chai from 'chai'; +import 'mocha'; +import { packageEventsService } from '../../src/packages/services/package-events-service'; + +const expect = chai.expect; + +function resetSingleton() { + while (true) { + try { + packageEventsService.notifyPackagesChanged(); + } catch { + break; + } + } +} + +describe('packageEventsService', () => { + const listeners: Array<() => void> = []; + + afterEach(() => { + for (const unsub of listeners) unsub(); + listeners.length = 0; + }); + + it('notifyPackagesChanged calls all listeners', () => { + const calls: string[] = []; + listeners.push(packageEventsService.onPackagesChanged(() => calls.push('a'))); + listeners.push(packageEventsService.onPackagesChanged(() => calls.push('b'))); + + packageEventsService.notifyPackagesChanged(); + + expect(calls).to.have.members(['a', 'b']); + }); + + it('onPackagesChanged returns an unsubscribe function', () => { + let callCount = 0; + const unsubscribe = packageEventsService.onPackagesChanged(() => { + callCount += 1; + }); + + packageEventsService.notifyPackagesChanged(); + expect(callCount).to.equal(1); + + unsubscribe(); + packageEventsService.notifyPackagesChanged(); + expect(callCount).to.equal(1); + }); + + it('a listener error does not block other listeners', () => { + const calls: string[] = []; + listeners.push( + packageEventsService.onPackagesChanged(() => { + throw new Error('boom'); + }), + ); + listeners.push(packageEventsService.onPackagesChanged(() => calls.push('ok'))); + + packageEventsService.notifyPackagesChanged(); + + expect(calls).to.deep.equal(['ok']); + }); + + it('multiple unsubscribes are safe', () => { + let callCount = 0; + const unsubscribe = packageEventsService.onPackagesChanged(() => callCount++); + + unsubscribe(); + unsubscribe(); + + packageEventsService.notifyPackagesChanged(); + expect(callCount).to.equal(0); + }); +}); diff --git a/apps/web/tests/mocha/project-import-utils.test.ts b/apps/web/tests/mocha/project-import-utils.test.ts new file mode 100644 index 00000000..c150a2fb --- /dev/null +++ b/apps/web/tests/mocha/project-import-utils.test.ts @@ -0,0 +1,63 @@ +import * as chai from 'chai'; +import 'mocha'; +import { + formatFileSize, + isAcceptedFile, +} from '../../src/project/components/project-import/project-import-utils'; + +const expect = chai.expect; + +function mockFile(name: string): File { + return new File([''], name); +} + +describe('isAcceptedFile', () => { + it('accepts .zip files', () => { + expect(isAcceptedFile(mockFile('project.zip'))).to.be.true; + }); + + it('accepts .tar files', () => { + expect(isAcceptedFile(mockFile('project.tar'))).to.be.true; + }); + + it('accepts .tar.gz files', () => { + expect(isAcceptedFile(mockFile('project.tar.gz'))).to.be.true; + }); + + it('accepts .tgz files', () => { + expect(isAcceptedFile(mockFile('project.tgz'))).to.be.true; + }); + + it('rejects .pdf files', () => { + expect(isAcceptedFile(mockFile('doc.pdf'))).to.be.false; + }); + + it('rejects files with no extension', () => { + expect(isAcceptedFile(mockFile('README'))).to.be.false; + }); + + it('is case-insensitive', () => { + expect(isAcceptedFile(mockFile('Project.ZIP'))).to.be.true; + expect(isAcceptedFile(mockFile('Project.TAR.GZ'))).to.be.true; + }); +}); + +describe('formatFileSize', () => { + it('formats bytes correctly', () => { + expect(formatFileSize(0)).to.equal('0 B'); + expect(formatFileSize(500)).to.equal('500 B'); + expect(formatFileSize(1023)).to.equal('1023 B'); + }); + + it('formats kilobytes correctly', () => { + expect(formatFileSize(1024)).to.equal('1.0 KB'); + expect(formatFileSize(1536)).to.equal('1.5 KB'); + expect(formatFileSize(1024 * 1024 - 1)).to.equal('1024.0 KB'); + }); + + it('formats megabytes correctly', () => { + expect(formatFileSize(1024 * 1024)).to.equal('1.00 MB'); + expect(formatFileSize(5 * 1024 * 1024)).to.equal('5.00 MB'); + expect(formatFileSize(1536 * 1024)).to.equal('1.50 MB'); + }); +}); diff --git a/apps/web/tests/playwright/index.spec.ts b/apps/web/tests/playwright/index.spec.ts index 53106441..4850a9b3 100644 --- a/apps/web/tests/playwright/index.spec.ts +++ b/apps/web/tests/playwright/index.spec.ts @@ -3,17 +3,9 @@ import { expect, test } from '@playwright/test'; test('renders the JacLy home screen', async ({ page }) => { await page.goto('/'); - // await expect( - // page.getByRole('heading', { name: 'Web development environment for the Jaculus platform' }), - // ).toBeVisible(); - // await expect( - // page.getByText( - // 'JacLy is a web IDE for creating programs for ESP32 microcontrollers. Development can happen either in the visual blocks editor or as text written in TypeScript.', - // ), - // ).toBeVisible(); + await expect(page.getByRole('link', { name: 'JacLy' })).toBeVisible(); - // await expect(page.getByText('Visual editor')).toBeVisible(); - // await expect(page.getByText('TypeScript text editor')).toBeVisible(); - // await expect(page.getByRole('heading', { name: 'Featured templates' })).toBeVisible(); - // await expect(page.getByRole('heading', { name: 'Recently opened projects' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible(); + + await expect(page.getByRole('heading', { level: 2 }).first()).toBeVisible(); }); From 7693b19af56f8fd99e8b6b6cf8f649b81b18b585 Mon Sep 17 00:00:00 2001 From: Jakub Andrysek Date: Sat, 16 May 2026 12:17:30 +0200 Subject: [PATCH 2/7] test: add 4 Playwright spec files (10 tests) for navigation and project pages - home.spec.ts: hero CTAs, template heading, header visibility - navigation.spec.ts: header link navigation, create/import links - project-new.spec.ts: heading + header rendering, search params - project-import.spec.ts: heading + tabs, submit disabled state Fix index.spec.ts selector ambiguity (JacLy matches template cards). --- apps/web/tests/playwright/home.spec.ts | 23 +++++++++++++++++++ apps/web/tests/playwright/index.spec.ts | 2 +- apps/web/tests/playwright/navigation.spec.ts | 23 +++++++++++++++++++ .../tests/playwright/project-import.spec.ts | 22 ++++++++++++++++++ apps/web/tests/playwright/project-new.spec.ts | 20 ++++++++++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/playwright/home.spec.ts create mode 100644 apps/web/tests/playwright/navigation.spec.ts create mode 100644 apps/web/tests/playwright/project-import.spec.ts create mode 100644 apps/web/tests/playwright/project-new.spec.ts diff --git a/apps/web/tests/playwright/home.spec.ts b/apps/web/tests/playwright/home.spec.ts new file mode 100644 index 00000000..283bca7e --- /dev/null +++ b/apps/web/tests/playwright/home.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +test.describe('home page', () => { + test('hero section has create project CTA', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('link', { name: /start new project/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /open recent projects/i })).toBeVisible(); + }); + + test('template section renders headings', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { level: 2 }).first()).toBeVisible(); + }); + + test('header is visible', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('link', { name: 'JacLy logo JacLy' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible(); + }); +}); diff --git a/apps/web/tests/playwright/index.spec.ts b/apps/web/tests/playwright/index.spec.ts index 4850a9b3..78fe0c0d 100644 --- a/apps/web/tests/playwright/index.spec.ts +++ b/apps/web/tests/playwright/index.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; test('renders the JacLy home screen', async ({ page }) => { await page.goto('/'); - await expect(page.getByRole('link', { name: 'JacLy' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'JacLy logo JacLy' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible(); diff --git a/apps/web/tests/playwright/navigation.spec.ts b/apps/web/tests/playwright/navigation.spec.ts new file mode 100644 index 00000000..0ffca0f6 --- /dev/null +++ b/apps/web/tests/playwright/navigation.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +test.describe('navigation', () => { + test('navigates between pages via header links', async ({ page }) => { + await page.goto('/'); + + await page.getByRole('link', { name: 'Projects' }).click(); + await expect(page).toHaveURL(/\/project$/); + + await page.getByRole('link', { name: 'Docs' }).click(); + await expect(page).toHaveURL(/\/docs$/); + + await page.getByRole('link', { name: 'JacLy logo JacLy' }).click(); + await expect(page).toHaveURL('/'); + }); + + test('project list page has create and import links', async ({ page }) => { + await page.goto('/project/'); + + await expect(page.getByRole('link', { name: 'Create' }).first()).toBeVisible(); + await expect(page.getByRole('link', { name: 'Import' }).first()).toBeVisible(); + }); +}); diff --git a/apps/web/tests/playwright/project-import.spec.ts b/apps/web/tests/playwright/project-import.spec.ts new file mode 100644 index 00000000..35bb30f9 --- /dev/null +++ b/apps/web/tests/playwright/project-import.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test'; + +test.describe('project import page', () => { + test('renders heading and tabs', async ({ page }) => { + await page.goto('/project/import'); + + await expect(page.getByRole('heading', { name: /import/i })).toBeVisible({ + timeout: 10000, + }); + + await expect(page.getByRole('tab', { name: 'File' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'URL' })).toBeVisible(); + }); + + test('submit button is disabled initially', async ({ page }) => { + await page.goto('/project/import'); + + await expect(page.getByRole('button', { name: /import/i }).first()).toBeDisabled({ + timeout: 10000, + }); + }); +}); diff --git a/apps/web/tests/playwright/project-new.spec.ts b/apps/web/tests/playwright/project-new.spec.ts new file mode 100644 index 00000000..2b65b3a7 --- /dev/null +++ b/apps/web/tests/playwright/project-new.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +test.describe('project new page', () => { + test('renders heading and header', async ({ page }) => { + await page.goto('/project/new'); + + await expect(page.getByRole('heading', { name: /new project/i })).toBeVisible({ + timeout: 10000, + }); + await expect(page.getByRole('link', { name: 'JacLy logo JacLy' })).toBeVisible(); + }); + + test('renders with type from search params', async ({ page }) => { + await page.goto('/project/new?type=code'); + + await expect(page.getByRole('heading', { name: /new project/i })).toBeVisible({ + timeout: 10000, + }); + }); +}); From 4e4e470420b573f56491a41b67f0c898ab703374 Mon Sep 17 00:00:00 2001 From: Jakub Andrysek Date: Sat, 16 May 2026 12:24:59 +0200 Subject: [PATCH 3/7] fix: use exact link matching to prevent cross-browser strict mode failures Project/Docs links matched 'Open recent projects'/'Browse all projects' in WebKit/Firefox, causing Playwright strict mode violation. Use exact: true for nav link selectors. --- apps/web/tests/playwright/navigation.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/playwright/navigation.spec.ts b/apps/web/tests/playwright/navigation.spec.ts index 0ffca0f6..1653e99b 100644 --- a/apps/web/tests/playwright/navigation.spec.ts +++ b/apps/web/tests/playwright/navigation.spec.ts @@ -4,10 +4,10 @@ test.describe('navigation', () => { test('navigates between pages via header links', async ({ page }) => { await page.goto('/'); - await page.getByRole('link', { name: 'Projects' }).click(); + await page.getByRole('link', { name: 'Projects', exact: true }).click(); await expect(page).toHaveURL(/\/project$/); - await page.getByRole('link', { name: 'Docs' }).click(); + await page.getByRole('link', { name: 'Docs', exact: true }).click(); await expect(page).toHaveURL(/\/docs$/); await page.getByRole('link', { name: 'JacLy logo JacLy' }).click(); From 728439c82a032979f0749c6ce65a38e559e502f7 Mon Sep 17 00:00:00 2001 From: Jakub Andrysek Date: Sat, 16 May 2026 13:44:07 +0200 Subject: [PATCH 4/7] test: add Playwright tests for project-new full flow, project list, 404, docs - project-new: fill name, select first template via button card, click create, wait for Creating... state or Blockly editor (.blocklyMainBackground) - project-list: verify page renders with heading + create/import links - not-found: verify 404 text, go home link, go back button - docs: verify layout renders, dynamic doc page param works 45/45 tests pass across chromium, firefox, webkit. --- apps/web/tests/playwright/docs.spec.ts | 15 ++++++++++ apps/web/tests/playwright/not-found.spec.ts | 10 +++++++ .../web/tests/playwright/project-list.spec.ts | 11 +++++++ apps/web/tests/playwright/project-new.spec.ts | 30 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 apps/web/tests/playwright/docs.spec.ts create mode 100644 apps/web/tests/playwright/not-found.spec.ts create mode 100644 apps/web/tests/playwright/project-list.spec.ts diff --git a/apps/web/tests/playwright/docs.spec.ts b/apps/web/tests/playwright/docs.spec.ts new file mode 100644 index 00000000..f6401a06 --- /dev/null +++ b/apps/web/tests/playwright/docs.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; + +test.describe('docs page', () => { + test('renders the docs layout with sidebar', async ({ page }) => { + await page.goto('/docs/'); + + await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 10000 }); + }); + + test('navigates to a docs page', async ({ page }) => { + await page.goto('/docs/faq'); + + await expect(page.locator('main')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/apps/web/tests/playwright/not-found.spec.ts b/apps/web/tests/playwright/not-found.spec.ts new file mode 100644 index 00000000..429c7437 --- /dev/null +++ b/apps/web/tests/playwright/not-found.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from '@playwright/test'; + +test('displays 404 page with navigation options', async ({ page }) => { + await page.goto('/nonexistent-page'); + + await expect(page.getByText('404')).toBeVisible(); + + await expect(page.getByRole('link', { name: /go home/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /go back/i })).toBeVisible(); +}); diff --git a/apps/web/tests/playwright/project-list.spec.ts b/apps/web/tests/playwright/project-list.spec.ts new file mode 100644 index 00000000..bd62c9a6 --- /dev/null +++ b/apps/web/tests/playwright/project-list.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from '@playwright/test'; + +test.describe('project list page', () => { + test('page renders with header and actions', async ({ page }) => { + await page.goto('/project/'); + + await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('link', { name: 'Create' }).first()).toBeVisible(); + await expect(page.getByRole('link', { name: 'Import' }).first()).toBeVisible(); + }); +}); diff --git a/apps/web/tests/playwright/project-new.spec.ts b/apps/web/tests/playwright/project-new.spec.ts index 2b65b3a7..892d9dec 100644 --- a/apps/web/tests/playwright/project-new.spec.ts +++ b/apps/web/tests/playwright/project-new.spec.ts @@ -17,4 +17,34 @@ test.describe('project new page', () => { timeout: 10000, }); }); + + test('fills name, selects first template, and creates project', async ({ page }) => { + await page.goto('/project/new?type=jacly'); + + await expect(page.getByRole('heading', { name: /new project/i })).toBeVisible({ + timeout: 10000, + }); + + const nameInput = page.getByPlaceholder(/my awesome project/i); + await expect(nameInput).toBeVisible({ timeout: 15000 }); + await nameInput.fill('test-playwright-project'); + + const firstTemplate = page + .locator('#root') + .getByRole('button') + .filter({ + hasText: /template-/, + }) + .first(); + await expect(firstTemplate).toBeVisible({ timeout: 15000 }); + await firstTemplate.click(); + + const createBtn = page.getByRole('button', { name: /create project/i }); + await expect(createBtn).toBeEnabled({ timeout: 5000 }); + await createBtn.click(); + + await expect( + page.getByRole('button', { name: /creating/i }).or(page.locator('.blocklyMainBackground')), + ).toBeAttached({ timeout: 30000 }); + }); }); From 7532b940dac2fac74a4b7380d991696bd987cdb1 Mon Sep 17 00:00:00 2001 From: Jakub Andrysek Date: Sat, 16 May 2026 13:52:45 +0200 Subject: [PATCH 5/7] test: validate full project creation flow to Blockly editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify complete flow: name input → template select → create → 'Creating...' state → redirect to /project/ → editor page (no GeneralHeader nav) → loading spinner gone → Blockly workspace (.blocklyMainBackground) visible → project name + editor controls visible. --- apps/web/tests/playwright/project-new.spec.ts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/playwright/project-new.spec.ts b/apps/web/tests/playwright/project-new.spec.ts index 892d9dec..df767296 100644 --- a/apps/web/tests/playwright/project-new.spec.ts +++ b/apps/web/tests/playwright/project-new.spec.ts @@ -18,7 +18,7 @@ test.describe('project new page', () => { }); }); - test('fills name, selects first template, and creates project', async ({ page }) => { + test('fills name, selects template, creates project, and opens editor', async ({ page }) => { await page.goto('/project/new?type=jacly'); await expect(page.getByRole('heading', { name: /new project/i })).toBeVisible({ @@ -32,9 +32,7 @@ test.describe('project new page', () => { const firstTemplate = page .locator('#root') .getByRole('button') - .filter({ - hasText: /template-/, - }) + .filter({ hasText: /template-/ }) .first(); await expect(firstTemplate).toBeVisible({ timeout: 15000 }); await firstTemplate.click(); @@ -43,8 +41,22 @@ test.describe('project new page', () => { await expect(createBtn).toBeEnabled({ timeout: 5000 }); await createBtn.click(); - await expect( - page.getByRole('button', { name: /creating/i }).or(page.locator('.blocklyMainBackground')), - ).toBeAttached({ timeout: 30000 }); + await expect(page.getByRole('button', { name: /creating/i })).toBeAttached({ + timeout: 10000, + }); + + await expect(page).toHaveURL(/\/project\//, { timeout: 30000 }); + + await expect(page.getByRole('link', { name: 'Projects', exact: true })).not.toBeAttached({ + timeout: 5000, + }); + + await page.locator('svg.lucide-loader-2').waitFor({ state: 'hidden', timeout: 30000 }); + + await expect(page.locator('.blocklyMainBackground')).toBeVisible({ timeout: 15000 }); + + await expect(page.getByRole('button', { name: /test-playwright-project/ })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible(); + await expect(page.getByText('File Explorer').first()).toBeVisible(); }); }); From 590013582f5e06f713b673b3ff4b3272cb6016e9 Mon Sep 17 00:00:00 2001 From: Jakub Andrysek Date: Sat, 16 May 2026 14:52:29 +0200 Subject: [PATCH 6/7] fix: remove unused resetSingleton function in package-events test --- apps/web/tests/mocha/package-events-service.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/web/tests/mocha/package-events-service.test.ts b/apps/web/tests/mocha/package-events-service.test.ts index 06d1df7b..ae050c09 100644 --- a/apps/web/tests/mocha/package-events-service.test.ts +++ b/apps/web/tests/mocha/package-events-service.test.ts @@ -4,16 +4,6 @@ import { packageEventsService } from '../../src/packages/services/package-events const expect = chai.expect; -function resetSingleton() { - while (true) { - try { - packageEventsService.notifyPackagesChanged(); - } catch { - break; - } - } -} - describe('packageEventsService', () => { const listeners: Array<() => void> = []; From 717fd45f303a71bacc8757b1dac29877d0ae7c0d Mon Sep 17 00:00:00 2001 From: Jakub Andrysek Date: Sat, 16 May 2026 17:02:51 +0200 Subject: [PATCH 7/7] fix: update test script to run only Mocha tests --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 94e87231..532b924c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "generate:schema": "tsx scripts/generate-schema.ts", "type-check": "pnpm run paraglide:compile && tsc --noEmit", "type-check:watch": "tsc --noEmit --watch", - "test": "pnpm run test:mocha && pnpm run test:playwright", + "test": "pnpm run test:mocha", "test:mocha": "mocha", "test:playwright": "pnpm exec playwright test", "test:playwright:ui": "pnpm exec playwright test --ui",