Skip to content

Commit 8ef4f7a

Browse files
committed
feat: Improve wasmJs and iOS database handling and tests
- Refactor WebAssembly (wasmJs) database worker to support both SQLite3-WASM/OPFS and SQL.js as a fallback. - Update `webpack.config.d/sqljs-config.js` to dynamically find the repo root, copy compose resources, and ensure SQLite WASM assets are available. - Add `deleteDatabase` functionality for iOS to properly clean up database files, including WAL and SHM, improving test isolation. - Configure wasmJs browser tests to run with Karma and Chrome, and only execute if Chrome is available. - Stabilize UI tests by waiting for text fields to be displayed before interaction and wrapping navigation in the main dispatcher. - Remove schema creation from `IosSafeRepo` as it's handled by the database driver. - Set `duplicatesStrategy` to `EXCLUDE` for `wasmJsTestProcessResources` and `wasmJsBrowserDistribution` Gradle tasks to prevent build failures.
1 parent 5ca8eb5 commit 8ef4f7a

File tree

27 files changed

+949
-81
lines changed

27 files changed

+949
-81
lines changed

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
- Desktop app: `./gradlew :app:desktop:run` (launches JVM desktop Compose app).
2222
- Desktop UI tests: `./gradlew :app:desktop:jvmTest` (uses `uiTestJUnit4`).
2323
- iOS: `cd iosApp && pod install` then open `iosApp/iosApp.xcworkspace` in Xcode and run. Regenerate podspec if needed: `./gradlew :app:ios-kit:podspec`.
24+
- iOS UI tests: `./gradlew :app:ios-kit:iosSimulatorArm64Test` (requires iOS simulator; uses multiplatform Compose UI tests).
2425
- Web app: `./gradlew :app:web:wasmJsBrowserDevelopmentRun --continuous` (launches the web app in a browser with hot reload).
26+
- Web UI tests: `./gradlew :app:web:wasmJsBrowserTest` (requires Chrome binary via `CHROME_BIN` environment variable; uses Karma/Chrome headless).
2527
- Build without iOS link tasks: `./gradle/build_quick.sh` (see [gradle/build_quick.sh](gradle/build_quick.sh))
2628

2729
## AI Agent Workflow for Verifying Changes
@@ -40,6 +42,8 @@ After making changes, AI agents must perform the following checks sequentially.
4042
- Unit tests: Kotlin Multiplatform `kotlin("test")`/JUnit. Run all with `./gradlew test` or per module (e.g., `:ui:shared:jvmTest`).
4143
- Android UI tests: Espresso/Compose in `app/android/src/androidTest`. Run with `connectedCheck`.
4244
- Desktop UI tests: in `app/desktop/src/jvmTest` using `uiTestJUnit4`.
45+
- iOS UI tests: Multiplatform Compose UI tests in `app/ios-kit/src/commonTest`. Run with `:app:ios-kit:iosSimulatorArm64Test`.
46+
- Web UI tests: Multiplatform Compose UI tests in `app/web/src/wasmJsTest`. Run with `:app:web:wasmJsBrowserTest` (requires `CHROME_BIN` environment variable).
4347
- Name tests `FooBarTest.kt`; prefer Given_When_Then method names.
4448

4549
## Commit & Pull Request Guidelines

app/ios-kit/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,44 @@ dependencies {
358358

359359
Kotlin code can be debugged from Xcode with proper symbol mapping.
360360

361+
## Testing
362+
363+
### UI Tests
364+
365+
The iOS kit includes multiplatform Compose UI tests that extend `CommonUiTests` from the `ui/test` module:
366+
367+
**Location**: `app/ios-kit/src/commonTest/kotlin/IosUiTests.kt`
368+
369+
**Test Coverage**:
370+
- CRUD operations
371+
- Title editing after create/save
372+
- Database prepopulation
373+
- Encryption flow
374+
- Password settings
375+
- Locale switching
376+
377+
**Running Tests**:
378+
379+
```bash
380+
# Requires iOS Simulator to be running
381+
./gradlew :app:ios-kit:iosSimulatorArm64Test
382+
```
383+
384+
**Test Configuration**:
385+
- Tests run on iOS Simulator (arm64)
386+
- Uses Compose Multiplatform testing API
387+
- Database is automatically cleaned up before each test
388+
- Handles iOS-specific database file cleanup (WAL, journal files)
389+
390+
**Database Management**:
391+
Tests use improved database deletion that properly handles:
392+
- Main database file (`notes.db`)
393+
- Write-Ahead Logging files (`notes.db-wal`)
394+
- Shared memory files (`notes.db-shm`)
395+
- Journal files (`notes.db-journal`)
396+
397+
This ensures clean test state and prevents test interference.
398+
361399
## Updating Framework
362400

363401
After changing Kotlin code:

app/ios-kit/build.gradle.kts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
@file:OptIn(ExperimentalComposeLibrary::class)
2+
13
import org.gradle.internal.os.OperatingSystem
4+
import org.jetbrains.compose.ExperimentalComposeLibrary
25

36
plugins {
47
alias(libs.plugins.kotlin.multiplatform)
@@ -39,5 +42,13 @@ kotlin {
3942
implementation(project.dependencies.platform(libs.koin.bom))
4043
api(libs.koin.core)
4144
}
45+
commonTest.dependencies {
46+
implementation(kotlin("test"))
47+
implementation(projects.ui.test)
48+
implementation(compose.uiTest)
49+
implementation(compose.materialIconsExtended)
50+
implementation(libs.androidx.lifecycle.runtime.compose)
51+
implementation(libs.androidx.lifecycle.runtime.testing)
52+
}
4253
}
4354
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.softartdev.notedelight
2+
3+
import kotlinx.coroutines.test.TestResult
4+
import kotlin.test.AfterTest
5+
import kotlin.test.BeforeTest
6+
import kotlin.test.Test
7+
8+
class IosUiTests : CommonUiTests() {
9+
10+
@BeforeTest
11+
override fun setUp() = super.setUp()
12+
13+
@AfterTest
14+
override fun tearDown() = super.tearDown()
15+
16+
@Test
17+
override fun crudNoteTest(): TestResult = super.crudNoteTest()
18+
19+
@Test
20+
override fun editTitleAfterCreateTest(): TestResult = super.editTitleAfterCreateTest()
21+
22+
@Test
23+
override fun editTitleAfterSaveTest(): TestResult = super.editTitleAfterSaveTest()
24+
25+
@Test
26+
override fun prepopulateDatabase(): TestResult = super.prepopulateDatabase()
27+
28+
@Test
29+
override fun flowAfterCryptTest(): TestResult = super.flowAfterCryptTest()
30+
31+
@Test
32+
override fun settingPasswordTest(): TestResult = super.settingPasswordTest()
33+
34+
@Test
35+
override fun localeTest(): TestResult = super.localeTest()
36+
}
Binary file not shown.

app/web/README.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,9 +399,43 @@ self.addEventListener('install', (event) => {
399399

400400
## Testing
401401

402+
### UI Tests
403+
404+
The web app includes multiplatform Compose UI tests that extend `CommonUiTests` from the `ui/test` module:
405+
406+
**Location**: `app/web/src/wasmJsTest/kotlin/WebUiTests.kt`
407+
408+
**Test Coverage**:
409+
- CRUD operations
410+
- Title editing after create/save
411+
- Database prepopulation
412+
- Encryption flow
413+
- Password settings
414+
- Locale switching
415+
416+
**Running Tests**:
417+
418+
```bash
419+
# Requires CHROME_BIN environment variable
420+
export CHROME_BIN=/path/to/chrome
421+
./gradlew :app:web:wasmJsBrowserTest
422+
```
423+
424+
**Test Configuration**:
425+
- Uses Karma with Chrome headless for test execution
426+
- Automatically disabled if `CHROME_BIN` is not set
427+
- Tests use SQL.js fallback when SQLite3 WASM is not available (common in headless browsers)
428+
- Database is automatically cleaned up before each test
429+
430+
**Database Fallback for Tests**:
431+
The test worker (`sqlite.worker.js`) implements a fallback mechanism:
432+
1. First attempts to use official SQLite3 WASM with OPFS support
433+
2. Falls back to SQL.js (in-memory) if SQLite3 is unavailable
434+
3. This ensures tests can run in headless browser environments that may not support OPFS
435+
402436
### Unit Tests
403437

404-
Web-specific tests:
438+
Web-specific unit tests:
405439

406440
```kotlin
407441
// In wasmJsTest/
@@ -411,10 +445,15 @@ fun testWebInitialization() {
411445
}
412446
```
413447

414-
### Running Tests
448+
### Running All Tests
415449

416450
```bash
451+
# Unit tests
417452
./gradlew :app:web:wasmJsTest
453+
454+
# UI tests (requires CHROME_BIN)
455+
export CHROME_BIN=/path/to/chrome
456+
./gradlew :app:web:wasmJsBrowserTest
418457
```
419458

420459
### Browser Testing

app/web/build.gradle.kts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
@file:OptIn(ExperimentalWasmDsl::class)
1+
@file:OptIn(ExperimentalWasmDsl::class, ExperimentalComposeLibrary::class)
22

33
import de.undercouch.gradle.tasks.download.Download
4+
import org.jetbrains.compose.ExperimentalComposeLibrary
45
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
56
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
7+
import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest
8+
import org.gradle.api.file.DuplicatesStrategy
9+
import org.gradle.api.tasks.Copy
610

711
plugins {
812
alias(libs.plugins.kotlin.multiplatform)
@@ -17,6 +21,12 @@ kotlin {
1721
browser {
1822
val rootDirPath = project.rootDir.path
1923
val projectDirPath = project.projectDir.path
24+
testTask {
25+
useKarma {
26+
useChrome()
27+
useChromeHeadless()
28+
}
29+
}
2030
commonWebpackConfig {
2131
outputFileName = "composeApp.js"
2232
devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
@@ -34,6 +44,7 @@ kotlin {
3444
sourceSets {
3545
val wasmJsMain by getting {
3646
dependencies {
47+
implementation(projects.core.domain)
3748
implementation(projects.core.presentation)
3849
implementation(projects.ui.shared)
3950
implementation(compose.ui)
@@ -44,10 +55,27 @@ kotlin {
4455
}
4556
resources.srcDir(layout.buildDirectory.dir("sqlite"))
4657
}
47-
val wasmJsTest by getting
58+
val wasmJsTest by getting {
59+
dependencies {
60+
implementation(kotlin("test"))
61+
implementation(projects.ui.test)
62+
implementation(compose.uiTest)
63+
implementation(compose.materialIconsExtended)
64+
implementation(libs.androidx.lifecycle.runtime.compose)
65+
implementation(libs.androidx.lifecycle.runtime.testing)
66+
}
67+
resources.srcDir(layout.buildDirectory.dir("sqlite"))
68+
}
4869
}
4970
}
5071

72+
val chromeBinaryFromEnv = providers.environmentVariable("CHROME_BIN").orNull
73+
val hasChromeForTests = chromeBinaryFromEnv?.let { file(it).exists() } == true
74+
75+
tasks.named<KotlinJsTest>("wasmJsBrowserTest").configure {
76+
enabled = hasChromeForTests
77+
}
78+
5179
val sqliteVersion = 3500400 // See https://sqlite.org/download.html for the latest wasm build version
5280
val sqliteDownload = tasks.register("sqliteDownload", Download::class.java) {
5381
src("https://sqlite.org/2025/sqlite-wasm-$sqliteVersion.zip")
@@ -70,3 +98,11 @@ val sqliteUnzip = tasks.register("sqliteUnzip", Copy::class.java) {
7098
tasks.named("wasmJsProcessResources").configure {
7199
dependsOn(sqliteUnzip)
72100
}
101+
tasks.named<Copy>("wasmJsTestProcessResources").configure {
102+
dependsOn(sqliteUnzip)
103+
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
104+
}
105+
106+
tasks.named<Sync>("wasmJsBrowserDistribution").configure {
107+
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
108+
}

app/web/src/wasmJsMain/resources/sqlite.worker.js

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,57 @@
1-
importScripts("sqlite3.js");
1+
let sqlite3Available = false;
2+
let useSqlJs = false;
3+
4+
try {
5+
importScripts("sqlite3.js");
6+
sqlite3Available = typeof sqlite3InitModule === "function";
7+
} catch (error) {
8+
sqlite3Available = false;
9+
}
210

311
let db = null;
412

513
async function createDatabase() {
6-
const sqlite3 = await sqlite3InitModule();
14+
if (sqlite3Available) {
15+
const sqlite3 = await sqlite3InitModule();
16+
17+
// This is the key part for OPFS support
18+
// It instructs SQLite to use the OPFS VFS.
19+
try {
20+
db = new sqlite3.oo1.DB("file:database.db?vfs=opfs", "c");
21+
return;
22+
} catch (error) {
23+
try {
24+
// Fallback for environments without OPFS support (e.g., headless browsers).
25+
db = new sqlite3.oo1.DB(":memory:", "c");
26+
return;
27+
} catch (fallbackError) {
28+
sqlite3Available = false;
29+
}
30+
}
31+
}
32+
33+
if (typeof initSqlJs !== "function") {
34+
importScripts("sql-wasm.js");
35+
}
36+
37+
const SQL = await initSqlJs({
38+
locateFile: (file) => file,
39+
});
40+
db = new SQL.Database();
41+
useSqlJs = true;
42+
}
743

8-
// This is the key part for OPFS support
9-
// It instructs SQLite to use the OPFS VFS.
10-
db = new sqlite3.oo1.DB("file:database.db?vfs=opfs", "c");
44+
function execSqlJs(sql, params) {
45+
const stmt = db.prepare(sql);
46+
if (params && params.length) {
47+
stmt.bind(params);
48+
}
49+
const rows = [];
50+
while (stmt.step()) {
51+
rows.push(stmt.get());
52+
}
53+
stmt.free();
54+
return rows;
1155
}
1256

1357
function handleMessage() {
@@ -19,25 +63,35 @@ function handleMessage() {
1963
throw new Error("exec: Missing query string");
2064
}
2165

66+
if (useSqlJs) {
67+
return postMessage({
68+
id: data.id,
69+
results: { values: execSqlJs(data.sql, data.params) },
70+
});
71+
}
72+
2273
return postMessage({
2374
id: data.id,
2475
results: { values: db.exec({ sql: data.sql, bind: data.params, returnValue: "resultRows" }) },
25-
})
76+
});
2677
case "begin_transaction":
27-
return postMessage({
28-
id: data.id,
29-
results: db.exec("BEGIN TRANSACTION;"),
30-
})
78+
if (useSqlJs) {
79+
db.exec("BEGIN TRANSACTION;");
80+
return postMessage({ id: data.id, results: [] });
81+
}
82+
return postMessage({ id: data.id, results: db.exec("BEGIN TRANSACTION;") });
3183
case "end_transaction":
32-
return postMessage({
33-
id: data.id,
34-
results: db.exec("END TRANSACTION;"),
35-
})
84+
if (useSqlJs) {
85+
db.exec("END TRANSACTION;");
86+
return postMessage({ id: data.id, results: [] });
87+
}
88+
return postMessage({ id: data.id, results: db.exec("END TRANSACTION;") });
3689
case "rollback_transaction":
37-
return postMessage({
38-
id: data.id,
39-
results: db.exec("ROLLBACK TRANSACTION;"),
40-
})
90+
if (useSqlJs) {
91+
db.exec("ROLLBACK TRANSACTION;");
92+
return postMessage({ id: data.id, results: [] });
93+
}
94+
return postMessage({ id: data.id, results: db.exec("ROLLBACK TRANSACTION;") });
4195
default:
4296
throw new Error(`Unsupported action: ${data && data.action}`);
4397
}

0 commit comments

Comments
 (0)