@jskit-ai/mobile-capacitor 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ createGlobalCapacitorAppAdapter,
5
+ createNoopCapacitorAppAdapter
6
+ } from "../src/client/runtime/globalCapacitorAppAdapter.js";
7
+ import { createMobileCapacitorRuntime } from "../src/client/runtime/mobileCapacitorRuntime.js";
8
+ import {
9
+ createCapacitorAwareFetch,
10
+ isCapacitorApiRequestTarget,
11
+ resolveCapacitorAbsoluteHttpUrl
12
+ } from "../src/client/runtime/apiRequestClient.js";
13
+ import {
14
+ createCapacitorAwareOAuthLaunchClient,
15
+ resolveCapacitorLaunchUrl
16
+ } from "../src/client/runtime/oauthLaunchClient.js";
17
+
18
+ test("createNoopCapacitorAppAdapter returns a stable disabled adapter", async () => {
19
+ const adapter = createNoopCapacitorAppAdapter();
20
+
21
+ assert.equal(adapter.available, false);
22
+ assert.equal(await adapter.getInitialLaunchUrl(), "");
23
+ assert.equal(typeof adapter.subscribeToLaunchUrls(() => {}), "function");
24
+ assert.equal(typeof adapter.subscribeToBackButton(() => {}), "function");
25
+ assert.equal(await adapter.exitApp(), false);
26
+ });
27
+
28
+ test("createGlobalCapacitorAppAdapter reads launch URLs from a Capacitor App plugin", async () => {
29
+ const listeners = new Map();
30
+ let exitCalls = 0;
31
+ const adapter = createGlobalCapacitorAppAdapter({
32
+ appPlugin: {
33
+ async getLaunchUrl() {
34
+ return {
35
+ url: "convict://w/acme"
36
+ };
37
+ },
38
+ addListener(eventName, handler) {
39
+ listeners.set(eventName, handler);
40
+ return {
41
+ remove() {
42
+ listeners.delete(eventName);
43
+ }
44
+ };
45
+ },
46
+ async exitApp() {
47
+ exitCalls += 1;
48
+ }
49
+ }
50
+ });
51
+
52
+ assert.equal(adapter.available, true);
53
+ assert.equal(await adapter.getInitialLaunchUrl(), "convict://w/acme");
54
+
55
+ const urls = [];
56
+ const unsubscribe = adapter.subscribeToLaunchUrls((url) => {
57
+ urls.push(url);
58
+ });
59
+ listeners.get("appUrlOpen")({
60
+ url: "convict://auth/login?code=abc"
61
+ });
62
+ assert.deepEqual(urls, ["convict://auth/login?code=abc"]);
63
+
64
+ const backEvents = [];
65
+ const unsubscribeBack = adapter.subscribeToBackButton((event) => {
66
+ backEvents.push(event);
67
+ });
68
+ listeners.get("backButton")({
69
+ canGoBack: true
70
+ });
71
+ assert.deepEqual(backEvents, [
72
+ {
73
+ canGoBack: true
74
+ }
75
+ ]);
76
+ assert.equal(await adapter.exitApp(), true);
77
+ assert.equal(exitCalls, 1);
78
+
79
+ unsubscribe();
80
+ unsubscribeBack();
81
+ await Promise.resolve();
82
+ await Promise.resolve();
83
+ assert.equal(listeners.has("appUrlOpen"), false);
84
+ assert.equal(listeners.has("backButton"), false);
85
+ });
86
+
87
+ test("createCapacitorAwareOAuthLaunchClient uses Capacitor Browser inside the shell", async () => {
88
+ const openCalls = [];
89
+ const launchClient = createCapacitorAwareOAuthLaunchClient({
90
+ adapter: {
91
+ available: true
92
+ },
93
+ apiBaseUrl: "https://api.example.com",
94
+ browserPlugin: {
95
+ async open(input = {}) {
96
+ openCalls.push(input);
97
+ }
98
+ }
99
+ });
100
+
101
+ const opened = await launchClient.open({
102
+ url: "/api/oauth/google/start?returnTo=%2Fw%2Facme"
103
+ });
104
+
105
+ assert.equal(opened, true);
106
+ assert.deepEqual(openCalls, [
107
+ {
108
+ url: "https://api.example.com/api/oauth/google/start?returnTo=%2Fw%2Facme"
109
+ }
110
+ ]);
111
+ });
112
+
113
+ test("resolveCapacitorLaunchUrl requires apiBaseUrl for relative shell launches", () => {
114
+ assert.throws(
115
+ () => resolveCapacitorLaunchUrl("/api/oauth/google/start?returnTo=%2Fw%2Facme", ""),
116
+ /config\.mobile\.apiBaseUrl is required/
117
+ );
118
+ });
119
+
120
+ test("resolveCapacitorAbsoluteHttpUrl resolves relative API URLs against apiBaseUrl", () => {
121
+ assert.equal(
122
+ resolveCapacitorAbsoluteHttpUrl("/api/session", "http://127.0.0.1:3000"),
123
+ "http://127.0.0.1:3000/api/session"
124
+ );
125
+ });
126
+
127
+ test("isCapacitorApiRequestTarget only rewrites known server endpoints", () => {
128
+ assert.equal(isCapacitorApiRequestTarget("/api/session"), true);
129
+ assert.equal(isCapacitorApiRequestTarget("/socket.io/?EIO=4"), true);
130
+ assert.equal(isCapacitorApiRequestTarget("https://localhost/api/session"), false);
131
+ assert.equal(isCapacitorApiRequestTarget("https://localhost/socket.io/?EIO=4"), false);
132
+ assert.equal(isCapacitorApiRequestTarget("/assets/index.js"), false);
133
+ assert.equal(isCapacitorApiRequestTarget("assets/index.js"), false);
134
+ });
135
+
136
+ test("createCapacitorAwareFetch rewrites relative API requests inside the shell", async () => {
137
+ const calls = [];
138
+ const wrappedFetch = createCapacitorAwareFetch({
139
+ fetchImpl: async (url, options) => {
140
+ calls.push({
141
+ url,
142
+ options
143
+ });
144
+ return {
145
+ ok: true
146
+ };
147
+ },
148
+ adapter: {
149
+ available: true
150
+ },
151
+ apiBaseUrl: "http://127.0.0.1:3000"
152
+ });
153
+
154
+ await wrappedFetch("/api/session", {
155
+ method: "GET"
156
+ });
157
+ await wrappedFetch("/socket.io/?EIO=4&transport=polling");
158
+ await wrappedFetch("/assets/index.js");
159
+
160
+ assert.deepEqual(calls, [
161
+ {
162
+ url: "http://127.0.0.1:3000/api/session",
163
+ options: {
164
+ method: "GET"
165
+ }
166
+ },
167
+ {
168
+ url: "http://127.0.0.1:3000/socket.io/?EIO=4&transport=polling",
169
+ options: undefined
170
+ },
171
+ {
172
+ url: "/assets/index.js",
173
+ options: undefined
174
+ }
175
+ ]);
176
+ });
177
+
178
+ test("createCapacitorAwareOAuthLaunchClient falls back to browser navigation outside the shell", async () => {
179
+ const assignedTargets = [];
180
+ const launchClient = createCapacitorAwareOAuthLaunchClient({
181
+ adapter: {
182
+ available: false
183
+ },
184
+ location: {
185
+ assign(target) {
186
+ assignedTargets.push(target);
187
+ }
188
+ }
189
+ });
190
+
191
+ const opened = await launchClient.open({
192
+ url: "/api/oauth/google/start?returnTo=%2Fw%2Facme"
193
+ });
194
+
195
+ assert.equal(opened, true);
196
+ assert.deepEqual(assignedTargets, [
197
+ "/api/oauth/google/start?returnTo=%2Fw%2Facme"
198
+ ]);
199
+ });
200
+
201
+ test("mobile capacitor runtime routes an initial launch URL through kernel mobile routing", async () => {
202
+ const replaceCalls = [];
203
+ const runtime = createMobileCapacitorRuntime({
204
+ router: {
205
+ currentRoute: {
206
+ value: {
207
+ fullPath: "/home"
208
+ }
209
+ },
210
+ async replace(target) {
211
+ replaceCalls.push(target);
212
+ }
213
+ },
214
+ mobileConfig: {
215
+ enabled: true,
216
+ auth: {
217
+ customScheme: "convict"
218
+ }
219
+ },
220
+ adapter: {
221
+ available: true,
222
+ async getInitialLaunchUrl() {
223
+ return "convict://w/acme";
224
+ },
225
+ subscribeToLaunchUrls() {
226
+ return () => {};
227
+ }
228
+ }
229
+ });
230
+
231
+ const targetPath = await runtime.initialize();
232
+
233
+ assert.equal(targetPath, "/w/acme");
234
+ assert.deepEqual(replaceCalls, ["/w/acme"]);
235
+ assert.deepEqual(runtime.getState(), {
236
+ initialized: true,
237
+ available: true,
238
+ enabled: true,
239
+ lastAppliedPath: "/w/acme"
240
+ });
241
+ });
242
+
243
+ test("mobile capacitor runtime waits for auth guard initialization before applying launch routing", async () => {
244
+ const order = [];
245
+ const replaceCalls = [];
246
+ const runtime = createMobileCapacitorRuntime({
247
+ router: {
248
+ currentRoute: {
249
+ value: {
250
+ fullPath: "/home"
251
+ }
252
+ },
253
+ async replace(target) {
254
+ order.push(`router.replace:${target}`);
255
+ replaceCalls.push(target);
256
+ }
257
+ },
258
+ mobileConfig: {
259
+ enabled: true,
260
+ auth: {
261
+ customScheme: "convict"
262
+ }
263
+ },
264
+ adapter: {
265
+ available: true,
266
+ async getInitialLaunchUrl() {
267
+ order.push("adapter.getInitialLaunchUrl");
268
+ return "convict://w/acme";
269
+ },
270
+ subscribeToLaunchUrls() {
271
+ return () => {};
272
+ }
273
+ },
274
+ authGuardRuntime: {
275
+ async initialize() {
276
+ order.push("auth.initialize:start");
277
+ await Promise.resolve();
278
+ order.push("auth.initialize:end");
279
+ return {
280
+ authenticated: true
281
+ };
282
+ },
283
+ async refresh() {
284
+ order.push("auth.refresh");
285
+ return {
286
+ authenticated: true
287
+ };
288
+ },
289
+ getState() {
290
+ return {
291
+ authenticated: true,
292
+ oauthDefaultProvider: "google"
293
+ };
294
+ }
295
+ }
296
+ });
297
+
298
+ const targetPath = await runtime.initialize();
299
+
300
+ assert.equal(targetPath, "/w/acme");
301
+ assert.deepEqual(replaceCalls, ["/w/acme"]);
302
+ assert.deepEqual(order, [
303
+ "auth.initialize:start",
304
+ "auth.initialize:end",
305
+ "adapter.getInitialLaunchUrl",
306
+ "router.replace:/w/acme"
307
+ ]);
308
+ });
309
+
310
+ test("mobile capacitor runtime resolves successful auth callbacks to the returned destination", async () => {
311
+ const replaceCalls = [];
312
+ const runtime = createMobileCapacitorRuntime({
313
+ router: {
314
+ currentRoute: {
315
+ value: {
316
+ fullPath: "/w/acme/settings"
317
+ }
318
+ },
319
+ async replace(target) {
320
+ replaceCalls.push(target);
321
+ }
322
+ },
323
+ mobileConfig: {
324
+ enabled: true,
325
+ auth: {
326
+ customScheme: "convict",
327
+ callbackPath: "/auth/login"
328
+ }
329
+ },
330
+ adapter: {
331
+ available: true,
332
+ async getInitialLaunchUrl() {
333
+ return "";
334
+ },
335
+ subscribeToLaunchUrls() {
336
+ return () => {};
337
+ }
338
+ },
339
+ placementRuntime: {
340
+ getContext() {
341
+ return {
342
+ surfaceConfig: {
343
+ defaultSurfaceId: "app"
344
+ }
345
+ };
346
+ }
347
+ },
348
+ authGuardRuntime: {
349
+ getState() {
350
+ return {
351
+ oauthDefaultProvider: "google"
352
+ };
353
+ },
354
+ async refresh() {
355
+ return {
356
+ authenticated: true
357
+ };
358
+ }
359
+ },
360
+ authCallbackCompleter: {
361
+ async completeFromUrl(input) {
362
+ assert.equal(input.url, "convict://auth/login?code=abc");
363
+ assert.equal(input.fallbackReturnTo, "/w/acme/settings");
364
+ assert.equal(input.defaultProvider, "google");
365
+ assert.equal(typeof input.refreshSession, "function");
366
+ return {
367
+ handled: true,
368
+ completed: true,
369
+ returnTo: "/w/acme/workouts/2026-05-07"
370
+ };
371
+ }
372
+ }
373
+ });
374
+
375
+ const targetPath = await runtime.applyIncomingUrl("convict://auth/login?code=abc", "launch-event");
376
+
377
+ assert.equal(targetPath, "/w/acme/workouts/2026-05-07");
378
+ assert.deepEqual(replaceCalls, ["/w/acme/workouts/2026-05-07"]);
379
+ });
380
+
381
+ test("mobile capacitor runtime falls back to the normalized callback route when auth completion does not finish", async () => {
382
+ const replaceCalls = [];
383
+ const runtime = createMobileCapacitorRuntime({
384
+ router: {
385
+ currentRoute: {
386
+ value: {
387
+ fullPath: "/home"
388
+ }
389
+ },
390
+ async replace(target) {
391
+ replaceCalls.push(target);
392
+ }
393
+ },
394
+ mobileConfig: {
395
+ enabled: true,
396
+ auth: {
397
+ customScheme: "convict",
398
+ callbackPath: "/auth/login"
399
+ }
400
+ },
401
+ adapter: {
402
+ available: true,
403
+ async getInitialLaunchUrl() {
404
+ return "";
405
+ },
406
+ subscribeToLaunchUrls() {
407
+ return () => {};
408
+ }
409
+ },
410
+ authCallbackCompleter: {
411
+ async completeFromUrl() {
412
+ return {
413
+ handled: true,
414
+ completed: false,
415
+ errorMessage: "OAuth provider is missing from callback."
416
+ };
417
+ }
418
+ }
419
+ });
420
+
421
+ const targetPath = await runtime.applyIncomingUrl("convict://auth/login?code=abc", "launch-event");
422
+
423
+ assert.equal(targetPath, "/auth/login?code=abc");
424
+ assert.deepEqual(replaceCalls, ["/auth/login?code=abc"]);
425
+ });
426
+
427
+ test("mobile capacitor runtime uses the Capacitor back button to go back or exit", async () => {
428
+ const replaceCalls = [];
429
+ const backCalls = [];
430
+ let backButtonHandler = null;
431
+ let exitCalls = 0;
432
+ const runtime = createMobileCapacitorRuntime({
433
+ router: {
434
+ currentRoute: {
435
+ value: {
436
+ fullPath: "/w/acme/workouts/2026-05-07"
437
+ }
438
+ },
439
+ async replace(target) {
440
+ replaceCalls.push(target);
441
+ },
442
+ back() {
443
+ backCalls.push("back");
444
+ }
445
+ },
446
+ mobileConfig: {
447
+ enabled: true,
448
+ auth: {
449
+ customScheme: "convict"
450
+ }
451
+ },
452
+ adapter: {
453
+ available: true,
454
+ async getInitialLaunchUrl() {
455
+ return "";
456
+ },
457
+ subscribeToLaunchUrls() {
458
+ return () => {};
459
+ },
460
+ subscribeToBackButton(handler) {
461
+ backButtonHandler = handler;
462
+ return () => {
463
+ backButtonHandler = null;
464
+ };
465
+ },
466
+ async exitApp() {
467
+ exitCalls += 1;
468
+ return true;
469
+ }
470
+ }
471
+ });
472
+
473
+ await runtime.initialize();
474
+ await backButtonHandler({
475
+ canGoBack: true
476
+ });
477
+ await backButtonHandler({
478
+ canGoBack: false
479
+ });
480
+
481
+ assert.deepEqual(replaceCalls, []);
482
+ assert.deepEqual(backCalls, ["back"]);
483
+ assert.equal(exitCalls, 1);
484
+
485
+ runtime.dispose();
486
+ assert.equal(backButtonHandler, null);
487
+ });