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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
run: pnpm test

- name: Install Playwright Browsers
run: pnpx playwright install --with-deps chromium
run: pnpm exec playwright install --with-deps chromium

- name: Run Playwright tests
run: pnpm test:e2e:only
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ npm-debug.log*
/playwright/.cache/
/playwright-report

db.sqlite
db*.sqlite

/build
/.svelte-kit
Expand Down
8 changes: 8 additions & 0 deletions e2e/specs/create-project-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { testWithUser as test } from './fixtures'

test.describe('create project', { tag: ['@foo-bar'] }, () => {
test('projects', async ({ page }) => {
await page.goto('/projects')
// TODO add test
})
})
23 changes: 23 additions & 0 deletions e2e/specs/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { test as base } from '@playwright/test'
import { migrate, undoMigration } from 'services/kysely/migrator.util'
import { setupUser } from './util'

export const test = base.extend({
page: async ({ page }, use) => {
// clean up the database
await undoMigration()

// set up the database
await migrate()

await use(page)
}
})

export const testWithUser = test.extend({
page: async ({ page }, use) => {
await setupUser(page.request)

await use(page)
}
})
6 changes: 5 additions & 1 deletion e2e/specs/new-user-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import test, { expect } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures'
import { waitForHydration } from './util'

test.describe('registration process', { tag: ['@foo-bar'] }, () => {
const testEmail = 'foo@bar.com'
const testPassword = 'abc123abc123'

test('registers foo bar and logs into the app', async ({ page, baseURL }) => {
await page.goto(`${baseURL!}/signup`)
await waitForHydration(page)

await test.step('sign up a new user', async () => {
const firstname = page.getByTestId('signup-firstname-input')
Expand Down Expand Up @@ -35,6 +38,7 @@ test.describe('registration process', { tag: ['@foo-bar'] }, () => {

const termsCheckbox = page.getByTestId('signup-terms-checkbox')
await expect(termsCheckbox).toBeVisible()
await termsCheckbox.focus()
await termsCheckbox.check()

const signUpCta = page.getByTestId('signup-cta')
Expand Down
39 changes: 39 additions & 0 deletions e2e/specs/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type APIRequestContext, type Page, expect } from '@playwright/test'

export async function waitForHydration(page: Page) {
await page.locator('.hydrated').waitFor({ state: 'visible' })
}

export async function setupUser(request: APIRequestContext) {
await register(request)
await login(request)
}

export async function register(request: APIRequestContext) {
const signup = await request.post('/signup', {
headers: {
origin: 'http://localhost:3000'
},
form: {
first_name: 'test',
last_name: 'test',
email: 'test@test.com',
password: 'password',
confirmPassword: 'password',
termsOfService: 'true'
}
})

expect(signup.ok()).toBeTruthy()
}

export async function login(request: APIRequestContext) {
const login = await request.post('/login', {
headers: {
origin: 'http://localhost:3000'
},
form: { email: 'test@test.com', password: 'password' }
})

expect(login.ok()).toBeTruthy()
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "pnpm run setup:db && vite dev",
"dev:e2e": "cross-env-shell DATABASE_LOCATION=dbe2e.sqlite \"pnpm run setup:db && vite dev\"",
"build": "vite build",
"preview": "pnpm run setup:db && vite preview",
"sync:svelte": "svelte-kit sync",
Expand All @@ -17,12 +18,14 @@
"test:integration": "cross-env DATABASE_LOCATION=:memory: vitest run --project integration",
"test:e2e": "pnpm run build && playwright test",
"test:e2e:only": "playwright test",
"test:e2e:local": "cross-env DATABASE_LOCATION=dbe2e.sqlite playwright test --headed --ui --config ./playwright.config.local.ts",
"---- DB ------------------------------------------------------------": "",
"setup:db": "pnpm migrate:latest && pnpm sync:db",
"migrate": "tsx ./services/src/kysely/migrator.ts",
"migrate:latest": "pnpm run migrate -- latest",
"migrate:up": "pnpm run migrate -- up",
"migrate:down": "pnpm run migrate -- down",
"migrate:reset": "pnpm run migrate -- reset",
"sync:db": "cross-env DATABASE_URL=db.sqlite kysely-codegen",
"---- DOCS ----------------------------------------------------------": "",
"docs:dev": "vitepress dev docs",
Expand Down
27 changes: 27 additions & 0 deletions playwright.config.local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type PlaywrightTestConfig, devices } from '@playwright/test'

const WEB_SERVER_PORT = 3000
const config: PlaywrightTestConfig = {
testDir: './e2e/specs',
outputDir: './e2e/results',
projects: [
{
name: 'Chrome',
testMatch: /.*\.spec\.ts/,
use: {
...devices['Desktop Chrome']
}
}
],
use: {
baseURL: `http://localhost:${WEB_SERVER_PORT}`,
bypassCSP: true
},
webServer: {
command: 'pnpm run dev:e2e',
port: WEB_SERVER_PORT
},
reporter: [['list']]
}

export default config
102 changes: 53 additions & 49 deletions services/src/kysely/migrations/2024-04-28T09_init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,65 @@ import { Kysely, sql } from 'kysely'
import { createTableMigration } from '../migration.util'

export async function up(db: Kysely<unknown>): Promise<void> {
await createTableMigration(db, 'users')
.addColumn('email', 'text', (col) => col.unique().notNull())
.addColumn('first_name', 'text', (col) => col.notNull())
.addColumn('last_name', 'text', (col) => col.notNull())
.addColumn('role', 'text', (col) => col.defaultTo('user').notNull())
.addColumn('password_hash', 'text', (col) => col.notNull())
.execute()
await db.transaction().execute(async (tx) => {
await createTableMigration(tx, 'users')
.addColumn('email', 'text', (col) => col.unique().notNull())
.addColumn('first_name', 'text', (col) => col.notNull())
.addColumn('last_name', 'text', (col) => col.notNull())
.addColumn('role', 'text', (col) => col.defaultTo('user').notNull())
.addColumn('password_hash', 'text', (col) => col.notNull())
.execute()

await createTableMigration(db, 'projects')
.addColumn('name', 'text', (col) => col.unique().notNull())
.addColumn('base_language', 'integer', (col) =>
col.references('languages.id').onDelete('restrict').notNull()
)
.execute()
await createTableMigration(tx, 'projects')
.addColumn('name', 'text', (col) => col.unique().notNull())
.addColumn('base_language', 'integer', (col) =>
col.references('languages.id').onDelete('restrict').notNull()
)
.execute()

await createTableMigration(db, 'languages')
.addColumn('code', 'text', (col) => col.unique().notNull())
.addColumn('fallback_language', 'integer', (col) => col.references('languages.id'))
.addColumn('project_id', 'integer', (col) =>
col.references('project.id').onDelete('cascade').notNull()
)
.execute()
await createTableMigration(tx, 'languages')
.addColumn('code', 'text', (col) => col.unique().notNull())
.addColumn('fallback_language', 'integer', (col) => col.references('languages.id'))
.addColumn('project_id', 'integer', (col) =>
col.references('project.id').onDelete('cascade').notNull()
)
.execute()

await createTableMigration(db, 'keys')
.addColumn('project_id', 'integer', (col) =>
col.references('projects.id').onDelete('cascade').notNull()
)
.addColumn('name', 'text', (col) => col.unique().notNull())
.execute()
await createTableMigration(tx, 'keys')
.addColumn('project_id', 'integer', (col) =>
col.references('projects.id').onDelete('cascade').notNull()
)
.addColumn('name', 'text', (col) => col.unique().notNull())
.execute()

await createTableMigration(db, 'translations')
.addColumn('key_id', 'integer', (col) =>
col.references('keys.id').onDelete('cascade').notNull()
)
.addColumn('language_id', 'integer', (col) =>
col.references('languages.id').onDelete('cascade').notNull()
)
.addColumn('value', 'text')
.execute()
await createTableMigration(tx, 'translations')
.addColumn('key_id', 'integer', (col) =>
col.references('keys.id').onDelete('cascade').notNull()
)
.addColumn('language_id', 'integer', (col) =>
col.references('languages.id').onDelete('cascade').notNull()
)
.addColumn('value', 'text')
.execute()

await createTableMigration(db, 'projects_users', false, false)
.addColumn('project_id', 'integer', (col) => col.references('projects.id').notNull())
.addColumn('user_id', 'integer', (col) => col.references('user.id').notNull())
.addColumn('permission', 'text', (col) =>
col.check(sql`permission in ('READONLY', 'WRITE', 'ADMIN')`)
)
.addPrimaryKeyConstraint('projects_users_pk', ['project_id', 'user_id'])
.execute()
await createTableMigration(tx, 'projects_users', false, false)
.addColumn('project_id', 'integer', (col) => col.references('projects.id').notNull())
.addColumn('user_id', 'integer', (col) => col.references('user.id').notNull())
.addColumn('permission', 'text', (col) =>
col.check(sql`permission in ('READONLY', 'WRITE', 'ADMIN')`)
)
.addPrimaryKeyConstraint('projects_users_pk', ['project_id', 'user_id'])
.execute()
})
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('users').execute()
await db.schema.dropTable('projects_users').execute()
await db.schema.dropTable('translations').execute()
await db.schema.dropTable('keys').execute()
await db.schema.dropTable('languages').execute()
await db.schema.dropTable('projects').execute()
await db.transaction().execute(async (tx) => {
await tx.schema.dropTable('users').execute()
await tx.schema.dropTable('projects_users').execute()
await tx.schema.dropTable('translations').execute()
await tx.schema.dropTable('keys').execute()
await tx.schema.dropTable('languages').execute()
await tx.schema.dropTable('projects').execute()
})
}
21 changes: 20 additions & 1 deletion services/src/kysely/migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import { MIGRATION_PROVIDER, getMigrator } from './migrator.util'
import * as process from 'node:process'
import minimist from 'minimist'
import { pick } from 'typesafe-utils'
import { NO_MIGRATIONS } from 'kysely'

function highlight(text: string) {
return `\x1b[32m${text}\x1b[0m`
}

function highlightRed(text: string) {
return `\x1b[31m${text}\x1b[0m`
}

const consoleWithPrefix = (prefix: string): Console =>
new Proxy(global.console, {
get<Target, T extends keyof Target>(target: Target, prop: T) {
Expand Down Expand Up @@ -67,12 +72,26 @@ async function main() {
if (!results || !results.length) return console.info(highlight('Database is on base version'))

console.info(
`Migrated to the previous version (DOWN)\n${highlight(results.map(pick('migrationName')).join('\n'))}`
`Migrated to the previous version (DOWN)\n${highlightRed(results.map(pick('migrationName')).join('\n'))}`
)

break
}

case 'reset': {
const { results, error } = await migrator.migrateTo(NO_MIGRATIONS)

if (error) return console.error(error)
if (!results || !results.length) return console.info(highlight('Database is on base version'))

console.info(`Undid all migrations`)
for (const result of results) {
console.info(`${highlightRed(result.migrationName)}`)
}

break
}

default: {
return console.error('Please supply a command')
}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script>
import '../app.pcss'
import { Toaster } from '$components/ui/sonner'
import { browser } from '$app/environment'
</script>

<main class="h-screen w-screen">
<main class:hydrated={browser} class="h-screen w-screen">
<slot />

<Toaster />
Expand Down