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", 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..ae050c09 --- /dev/null +++ b/apps/web/tests/mocha/package-events-service.test.ts @@ -0,0 +1,63 @@ +import * as chai from 'chai'; +import 'mocha'; +import { packageEventsService } from '../../src/packages/services/package-events-service'; + +const expect = chai.expect; + +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/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/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 53106441..78fe0c0d 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 logo 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(); }); diff --git a/apps/web/tests/playwright/navigation.spec.ts b/apps/web/tests/playwright/navigation.spec.ts new file mode 100644 index 00000000..1653e99b --- /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', exact: true }).click(); + await expect(page).toHaveURL(/\/project$/); + + await page.getByRole('link', { name: 'Docs', exact: true }).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/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-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-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 new file mode 100644 index 00000000..df767296 --- /dev/null +++ b/apps/web/tests/playwright/project-new.spec.ts @@ -0,0 +1,62 @@ +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, + }); + }); + + 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({ + 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 })).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(); + }); +});