@caperjs/core 0.7.0 → 0.7.2

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.
Files changed (64) hide show
  1. package/build/assetpack.mjs +35 -3
  2. package/build/defaults.mjs +39 -1
  3. package/build/index.mjs +13 -2
  4. package/build/internal/outputMarker.mjs +52 -0
  5. package/cli/doctor.mjs +205 -2
  6. package/cli/doctor.test.mjs +522 -1
  7. package/cli/native.mjs +808 -0
  8. package/cli/native.test.mjs +1039 -0
  9. package/cli.mjs +4 -0
  10. package/extras/css/fullscreen.css +8 -0
  11. package/extras/llms.txt +18 -0
  12. package/extras/skills/caper/SKILL.md +1 -0
  13. package/lib/{CaptionsPlugin-Boj-gm3A.js → CaptionsPlugin-COrofpHN.js} +2 -2
  14. package/lib/{CaptionsPlugin-Boj-gm3A.js.map → CaptionsPlugin-COrofpHN.js.map} +1 -1
  15. package/lib/{DataAdapter-Cra9dJ6W.js → DataAdapter-T-22-_D1.js} +2 -2
  16. package/lib/{DataAdapter-Cra9dJ6W.js.map → DataAdapter-T-22-_D1.js.map} +1 -1
  17. package/lib/{DebugRenderer-19S58S4h.js → DebugRenderer-ZN692Pbg.js} +4 -4
  18. package/lib/{DebugRenderer-19S58S4h.js.map → DebugRenderer-ZN692Pbg.js.map} +1 -1
  19. package/lib/{DevToolsPlugin-BTSkRsdS.js → DevToolsPlugin-CQXFoj1T.js} +3 -3
  20. package/lib/{DevToolsPlugin-BTSkRsdS.js.map → DevToolsPlugin-CQXFoj1T.js.map} +1 -1
  21. package/lib/{GSAPPlugin-BcL7vjyw.js → GSAPPlugin-CNkncyXS.js} +2 -2
  22. package/lib/{GSAPPlugin-BcL7vjyw.js.map → GSAPPlugin-CNkncyXS.js.map} +1 -1
  23. package/lib/{LayoutPlugin-BDhSngz_.js → LayoutPlugin-BuJgr9bd.js} +26 -26
  24. package/lib/{LayoutPlugin-BDhSngz_.js.map → LayoutPlugin-BuJgr9bd.js.map} +1 -1
  25. package/lib/{ScreenDebugPlugin-C6pxs_3O.js → ScreenDebugPlugin-kz9Mr6j1.js} +2 -2
  26. package/lib/{ScreenDebugPlugin-C6pxs_3O.js.map → ScreenDebugPlugin-kz9Mr6j1.js.map} +1 -1
  27. package/lib/{SpinePlugin-CrQhaK6r.js → SpinePlugin-DDzD-Qxd.js} +1099 -877
  28. package/lib/SpinePlugin-DDzD-Qxd.js.map +1 -0
  29. package/lib/{StatsPlugin-Cu625dmk.js → StatsPlugin-tyFGzVjy.js} +2 -2
  30. package/lib/{StatsPlugin-Cu625dmk.js.map → StatsPlugin-tyFGzVjy.js.map} +1 -1
  31. package/lib/{VoiceOverPlugin-D4rfHFyJ.js → VoiceOverPlugin-rTNz_9eb.js} +2 -2
  32. package/lib/{VoiceOverPlugin-D4rfHFyJ.js.map → VoiceOverPlugin-rTNz_9eb.js.map} +1 -1
  33. package/lib/caper.mjs +51 -51
  34. package/lib/caper.mjs.map +1 -1
  35. package/lib/{const-BQ2iMCtc.js → const-Cj14a5U2.js} +5 -3
  36. package/lib/const-Cj14a5U2.js.map +1 -0
  37. package/lib/plugins/FullScreenPlugin.d.ts +65 -0
  38. package/lib/plugins/FullScreenPlugin.d.ts.map +1 -1
  39. package/lib/plugins/spine/SpinePlugin.d.ts.map +1 -1
  40. package/lib/plugins/spine/SpinePlugin.test.d.ts +2 -0
  41. package/lib/plugins/spine/SpinePlugin.test.d.ts.map +1 -0
  42. package/lib/{registries-BmDw_GV1.js → registries-DmNGVLAi.js} +489 -446
  43. package/lib/registries-DmNGVLAi.js.map +1 -0
  44. package/lib/ui/Button.d.ts.map +1 -1
  45. package/lib/utils/platform.d.ts +5 -0
  46. package/lib/utils/platform.d.ts.map +1 -1
  47. package/lib/utils/platform.test.d.ts +2 -0
  48. package/lib/utils/platform.test.d.ts.map +1 -0
  49. package/lib/utils/types.d.ts +1 -1
  50. package/lib/utils/types.d.ts.map +1 -1
  51. package/package.json +3 -3
  52. package/src/plugins/FullScreenPlugin.test.ts +138 -0
  53. package/src/plugins/FullScreenPlugin.ts +132 -0
  54. package/src/plugins/spine/SpinePlugin.test.ts +31 -0
  55. package/src/plugins/spine/SpinePlugin.ts +9 -2
  56. package/src/ui/Button.test.ts +22 -0
  57. package/src/ui/Button.ts +7 -0
  58. package/src/utils/platform.test.ts +20 -0
  59. package/src/utils/platform.ts +6 -0
  60. package/src/utils/types.ts +5 -1
  61. package/src/version.ts +2 -2
  62. package/lib/SpinePlugin-CrQhaK6r.js.map +0 -1
  63. package/lib/const-BQ2iMCtc.js.map +0 -1
  64. package/lib/registries-BmDw_GV1.js.map +0 -1
@@ -0,0 +1,1039 @@
1
+ /**
2
+ * `caper native init` — Tauri v2 scaffolding for a Caper app.
3
+ */
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { afterEach, describe, expect, it } from 'vitest';
8
+
9
+ import {
10
+ addNativePlugin,
11
+ ANDROID_RUST_TARGETS,
12
+ androidNative,
13
+ appSlug,
14
+ commandsFor,
15
+ defaultIdentifier,
16
+ defaultPort,
17
+ findMainActivity,
18
+ initNative,
19
+ isValidIdentifier,
20
+ missingDeps,
21
+ missingRustTargets,
22
+ packageManagerFor,
23
+ parsePort,
24
+ patchAndroidScripts,
25
+ patchCapabilities,
26
+ patchPackageScripts,
27
+ patchTauriConfig,
28
+ planBuildRs,
29
+ planMainActivity,
30
+ renderMainActivity,
31
+ resolveAndroidEnv,
32
+ TAURI_PLUGIN_PERMISSIONS,
33
+ } from './native.mjs';
34
+
35
+ let tempDir = null;
36
+
37
+ afterEach(() => {
38
+ if (tempDir && fs.existsSync(tempDir)) {
39
+ fs.rmSync(tempDir, { recursive: true, force: true });
40
+ }
41
+ tempDir = null;
42
+ });
43
+
44
+ function makeTempDir() {
45
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caper-native-'));
46
+ return tempDir;
47
+ }
48
+
49
+ describe('appSlug', () => {
50
+ it('strips a scope, lowercases, and collapses non-alnum runs to a hyphen', () => {
51
+ expect(appSlug('@acme/My Game')).toBe('my-game');
52
+ });
53
+
54
+ it('trims leading/trailing hyphens', () => {
55
+ expect(appSlug('--Weird--Name--')).toBe('weird-name');
56
+ });
57
+
58
+ it('handles an unscoped name', () => {
59
+ expect(appSlug('SuperGame')).toBe('supergame');
60
+ });
61
+
62
+ it('collapses runs of punctuation to a single hyphen', () => {
63
+ expect(appSlug('foo___bar...baz')).toBe('foo-bar-baz');
64
+ });
65
+ });
66
+
67
+ describe('defaultIdentifier', () => {
68
+ it('builds a hyphen-free dev.caper.<slug> identifier (valid as an Android package name too)', () => {
69
+ expect(defaultIdentifier('my-game')).toBe('dev.caper.mygame');
70
+ });
71
+
72
+ it('always matches the reverse-DNS shape', () => {
73
+ expect(isValidIdentifier(defaultIdentifier('my-game'))).toBe(true);
74
+ expect(isValidIdentifier(defaultIdentifier('3d-shooter'))).toBe(true);
75
+ expect(isValidIdentifier(defaultIdentifier(''))).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe('isValidIdentifier', () => {
80
+ it('accepts a well-formed reverse-DNS identifier', () => {
81
+ expect(isValidIdentifier('dev.caper.my-game')).toBe(true);
82
+ expect(isValidIdentifier('com.example.app')).toBe(true);
83
+ });
84
+
85
+ it('rejects the tauri placeholder', () => {
86
+ expect(isValidIdentifier('com.tauri.dev')).toBe(false);
87
+ });
88
+
89
+ it('rejects malformed shapes', () => {
90
+ expect(isValidIdentifier('not-an-identifier')).toBe(false);
91
+ expect(isValidIdentifier('dev.')).toBe(false);
92
+ expect(isValidIdentifier('.dev.caper')).toBe(false);
93
+ expect(isValidIdentifier('Dev.Caper.App')).toBe(false);
94
+ expect(isValidIdentifier('')).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe('defaultPort', () => {
99
+ it('is stable for the same slug', () => {
100
+ expect(defaultPort('my-game')).toBe(defaultPort('my-game'));
101
+ });
102
+
103
+ it('is always within 3100-3999', () => {
104
+ for (const slug of ['a', 'my-game', 'zzzzzzzzzz', 'kitchen-sink', '']) {
105
+ const port = defaultPort(slug);
106
+ expect(port).toBeGreaterThanOrEqual(3100);
107
+ expect(port).toBeLessThanOrEqual(3999);
108
+ expect(port).not.toBe(3000);
109
+ }
110
+ });
111
+
112
+ it('differs for different slugs (not a constant)', () => {
113
+ const ports = new Set(['a', 'b', 'c', 'd', 'e'].map(defaultPort));
114
+ expect(ports.size).toBeGreaterThan(1);
115
+ });
116
+ });
117
+
118
+ describe('parsePort', () => {
119
+ it('accepts an integer in range', () => {
120
+ expect(parsePort('3123')).toBe(3123);
121
+ expect(parsePort('1024')).toBe(1024);
122
+ expect(parsePort('65535')).toBe(65535);
123
+ });
124
+
125
+ it('rejects out-of-range and non-integer values', () => {
126
+ expect(() => parsePort('1023')).toThrow();
127
+ expect(() => parsePort('65536')).toThrow();
128
+ expect(() => parsePort('abc')).toThrow();
129
+ expect(() => parsePort('3123.5')).toThrow();
130
+ expect(() => parsePort('')).toThrow();
131
+ });
132
+ });
133
+
134
+ describe('packageManagerFor', () => {
135
+ it('detects pnpm from pnpm-lock.yaml', () => {
136
+ const dir = makeTempDir();
137
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '', 'utf-8');
138
+ expect(packageManagerFor(dir)).toBe('pnpm');
139
+ });
140
+
141
+ it('detects yarn from yarn.lock', () => {
142
+ const dir = makeTempDir();
143
+ fs.writeFileSync(path.join(dir, 'yarn.lock'), '', 'utf-8');
144
+ expect(packageManagerFor(dir)).toBe('yarn');
145
+ });
146
+
147
+ it('detects bun from bun.lockb', () => {
148
+ const dir = makeTempDir();
149
+ fs.writeFileSync(path.join(dir, 'bun.lockb'), '', 'utf-8');
150
+ expect(packageManagerFor(dir)).toBe('bun');
151
+ });
152
+
153
+ it('detects npm from package-lock.json', () => {
154
+ const dir = makeTempDir();
155
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), '', 'utf-8');
156
+ expect(packageManagerFor(dir)).toBe('npm');
157
+ });
158
+
159
+ it('walks up parent directories to find a monorepo root lockfile', () => {
160
+ const dir = makeTempDir();
161
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '', 'utf-8');
162
+ const appDir = path.join(dir, 'apps', 'kitchen-sink');
163
+ fs.mkdirSync(appDir, { recursive: true });
164
+ expect(packageManagerFor(appDir)).toBe('pnpm');
165
+ });
166
+
167
+ it('defaults to npm when no lockfile is found', () => {
168
+ const dir = makeTempDir();
169
+ expect(packageManagerFor(dir)).toBe('npm');
170
+ });
171
+ });
172
+
173
+ describe('commandsFor', () => {
174
+ it('builds pnpm commands', () => {
175
+ const { dev, build, addDev, add } = commandsFor('pnpm', 3123);
176
+ expect(dev).toEqual({ cmd: 'pnpm', args: ['vite', '--port', '3123'] });
177
+ expect(build).toEqual({ cmd: 'pnpm', args: ['vite', 'build'] });
178
+ expect(addDev('@tauri-apps/cli@^2')).toEqual({ cmd: 'pnpm', args: ['add', '-D', '@tauri-apps/cli@^2'] });
179
+ expect(add('@caperjs/plugin-tauri', '@tauri-apps/api@^2')).toEqual({
180
+ cmd: 'pnpm',
181
+ args: ['add', '@caperjs/plugin-tauri', '@tauri-apps/api@^2'],
182
+ });
183
+ });
184
+
185
+ it('builds yarn commands', () => {
186
+ const { dev, build, addDev, add } = commandsFor('yarn', 3123);
187
+ expect(dev).toEqual({ cmd: 'yarn', args: ['vite', '--port', '3123'] });
188
+ expect(build).toEqual({ cmd: 'yarn', args: ['vite', 'build'] });
189
+ expect(addDev('@tauri-apps/cli@^2')).toEqual({ cmd: 'yarn', args: ['add', '-D', '@tauri-apps/cli@^2'] });
190
+ expect(add('@caperjs/plugin-tauri')).toEqual({ cmd: 'yarn', args: ['add', '@caperjs/plugin-tauri'] });
191
+ });
192
+
193
+ it('builds bun commands', () => {
194
+ const { dev, build, addDev, add } = commandsFor('bun', 3123);
195
+ expect(dev).toEqual({ cmd: 'bunx', args: ['vite', '--port', '3123'] });
196
+ expect(build).toEqual({ cmd: 'bunx', args: ['vite', 'build'] });
197
+ expect(addDev('@tauri-apps/cli@^2')).toEqual({ cmd: 'bun', args: ['add', '-D', '@tauri-apps/cli@^2'] });
198
+ expect(add('@caperjs/plugin-tauri')).toEqual({ cmd: 'bun', args: ['add', '@caperjs/plugin-tauri'] });
199
+ });
200
+
201
+ it('builds npm commands', () => {
202
+ const { dev, build, addDev, add } = commandsFor('npm', 3123);
203
+ expect(dev).toEqual({ cmd: 'npx', args: ['vite', '--port', '3123'] });
204
+ expect(build).toEqual({ cmd: 'npx', args: ['vite', 'build'] });
205
+ expect(addDev('@tauri-apps/cli@^2')).toEqual({ cmd: 'npm', args: ['install', '-D', '@tauri-apps/cli@^2'] });
206
+ expect(add('@caperjs/plugin-tauri', '@tauri-apps/api@^2')).toEqual({
207
+ cmd: 'npm',
208
+ args: ['install', '@caperjs/plugin-tauri', '@tauri-apps/api@^2'],
209
+ });
210
+ });
211
+ });
212
+
213
+ describe('patchTauriConfig', () => {
214
+ const conf = {
215
+ identifier: 'com.tauri.dev',
216
+ build: {
217
+ frontendDist: '../dist',
218
+ devUrl: 'http://localhost:3123',
219
+ beforeDevCommand: 'pnpm vite --port 3123',
220
+ beforeBuildCommand: 'pnpm vite build',
221
+ },
222
+ app: {
223
+ windows: [{ title: 'old title', width: 800, height: 600, resizable: true }],
224
+ security: { csp: null },
225
+ withGlobalTauri: true,
226
+ },
227
+ bundle: { active: true, targets: ['app'] },
228
+ };
229
+
230
+ it('sets identifier and window 0 title/width/height', () => {
231
+ const patched = patchTauriConfig(conf, { identifier: 'dev.caper.my-game', title: 'My Game' });
232
+
233
+ expect(patched.identifier).toBe('dev.caper.my-game');
234
+ expect(patched.app.windows[0]).toMatchObject({ title: 'My Game', width: 1280, height: 720 });
235
+ });
236
+
237
+ it('leaves app.security.csp and unrelated keys untouched', () => {
238
+ const patched = patchTauriConfig(conf, { identifier: 'dev.caper.my-game', title: 'My Game' });
239
+
240
+ expect(patched.app.security.csp).toBeNull();
241
+ expect(patched.app.withGlobalTauri).toBe(true);
242
+ expect(patched.build).toEqual(conf.build);
243
+ expect(patched.bundle).toEqual(conf.bundle);
244
+ expect(patched.app.windows[0].resizable).toBe(true);
245
+ });
246
+
247
+ it('does not mutate the original object', () => {
248
+ patchTauriConfig(conf, { identifier: 'dev.caper.my-game', title: 'My Game' });
249
+ expect(conf.identifier).toBe('com.tauri.dev');
250
+ expect(conf.app.windows[0].title).toBe('old title');
251
+ });
252
+ });
253
+
254
+ describe('patchPackageScripts', () => {
255
+ it('adds native:dev and native:build when absent', () => {
256
+ const pkg = { name: 'app', scripts: { dev: 'vite' } };
257
+ const patched = patchPackageScripts(pkg);
258
+ expect(patched.scripts).toEqual({ dev: 'vite', 'native:dev': 'tauri dev', 'native:build': 'tauri build' });
259
+ });
260
+
261
+ it('never overwrites an existing native:dev or native:build', () => {
262
+ const pkg = { name: 'app', scripts: { 'native:dev': 'echo custom', dev: 'vite' } };
263
+ const patched = patchPackageScripts(pkg);
264
+ expect(patched.scripts['native:dev']).toBe('echo custom');
265
+ expect(patched.scripts['native:build']).toBe('tauri build');
266
+ });
267
+
268
+ it('does not mutate the original object', () => {
269
+ const pkg = { name: 'app', scripts: { dev: 'vite' } };
270
+ patchPackageScripts(pkg);
271
+ expect(pkg.scripts['native:dev']).toBeUndefined();
272
+ });
273
+ });
274
+
275
+ describe('initNative', () => {
276
+ function scaffoldApp(dir, { pkg = {} } = {}) {
277
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '', 'utf-8');
278
+ fs.writeFileSync(
279
+ path.join(dir, 'package.json'),
280
+ JSON.stringify({ name: 'my-game', version: '1.0.0', ...pkg }, null, 2) + '\n',
281
+ 'utf-8',
282
+ );
283
+ }
284
+
285
+ function stubbedRun(calls) {
286
+ return (cmd, args, opts) => {
287
+ calls.push({ cmd, args, opts });
288
+ if (cmd === 'pnpm' && args[0] === 'tauri' && args[1] === 'init') {
289
+ const srcTauri = path.join(opts.cwd, 'src-tauri');
290
+ fs.mkdirSync(srcTauri, { recursive: true });
291
+ fs.writeFileSync(
292
+ path.join(srcTauri, 'tauri.conf.json'),
293
+ JSON.stringify(
294
+ {
295
+ identifier: 'com.tauri.dev',
296
+ build: {
297
+ frontendDist: '../dist',
298
+ devUrl: 'http://localhost:3123',
299
+ beforeDevCommand: 'pnpm vite --port 3123',
300
+ beforeBuildCommand: 'pnpm vite build',
301
+ },
302
+ app: {
303
+ windows: [{ title: 'my-game', width: 800, height: 600 }],
304
+ security: { csp: null },
305
+ },
306
+ bundle: { active: true, targets: ['app'] },
307
+ },
308
+ null,
309
+ 2,
310
+ ),
311
+ 'utf-8',
312
+ );
313
+ }
314
+ return { status: 0 };
315
+ };
316
+ }
317
+
318
+ it('does nothing and makes zero run calls when src-tauri already exists', async () => {
319
+ const dir = makeTempDir();
320
+ scaffoldApp(dir);
321
+ fs.mkdirSync(path.join(dir, 'src-tauri'));
322
+ const pkgBefore = fs.readFileSync(path.join(dir, 'package.json'), 'utf-8');
323
+
324
+ const calls = [];
325
+ const result = await initNative(dir, {}, { run: stubbedRun(calls) });
326
+
327
+ expect(result.status).toBe('already-initialized');
328
+ expect(calls).toHaveLength(0);
329
+ expect(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).toBe(pkgBefore);
330
+ });
331
+
332
+ it('fails clearly when there is no package.json', async () => {
333
+ const dir = makeTempDir();
334
+ const calls = [];
335
+
336
+ await expect(initNative(dir, {}, { run: stubbedRun(calls) })).rejects.toThrow(/package\.json/);
337
+ expect(calls).toHaveLength(0);
338
+ });
339
+
340
+ it('runs tauri init with the exact expected argv, then patches the config and package.json', async () => {
341
+ const dir = makeTempDir();
342
+ scaffoldApp(dir);
343
+ const calls = [];
344
+
345
+ const result = await initNative(dir, { port: 3123 }, { run: stubbedRun(calls) });
346
+
347
+ expect(result.status).toBe('ok');
348
+ expect(result.identifier).toBe('dev.caper.mygame');
349
+ expect(result.port).toBe(3123);
350
+
351
+ const addDevCalls = calls.filter((c) => c.args.includes('@tauri-apps/cli@^2'));
352
+ expect(addDevCalls).toHaveLength(1);
353
+ expect(addDevCalls[0].cmd).toBe('pnpm');
354
+ expect(addDevCalls[0].args).toEqual(['add', '-D', '@tauri-apps/cli@^2']);
355
+
356
+ const initCall = calls.find((c) => c.args[0] === 'tauri' && c.args[1] === 'init');
357
+ expect(initCall.cmd).toBe('pnpm');
358
+ expect(initCall.args).toEqual([
359
+ 'tauri',
360
+ 'init',
361
+ '--ci',
362
+ '--app-name',
363
+ 'my-game',
364
+ '--window-title',
365
+ 'my-game',
366
+ '--frontend-dist',
367
+ '../dist',
368
+ '--dev-url',
369
+ 'http://localhost:3123',
370
+ '--before-dev-command',
371
+ 'pnpm vite --port 3123',
372
+ '--before-build-command',
373
+ 'pnpm vite build',
374
+ ]);
375
+
376
+ const conf = JSON.parse(fs.readFileSync(path.join(dir, 'src-tauri/tauri.conf.json'), 'utf-8'));
377
+ expect(conf.identifier).toBe('dev.caper.mygame');
378
+ expect(conf.app.windows[0]).toMatchObject({ title: 'my-game', width: 1280, height: 720 });
379
+ expect(conf.app.security.csp).toBeNull();
380
+ expect(conf.bundle).toEqual({ active: true, targets: ['app'] });
381
+
382
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
383
+ expect(pkg.scripts['native:dev']).toBe('tauri dev');
384
+ expect(pkg.scripts['native:build']).toBe('tauri build');
385
+ });
386
+
387
+ it('skips addDev when @tauri-apps/cli is already a devDependency', async () => {
388
+ const dir = makeTempDir();
389
+ scaffoldApp(dir, { pkg: { devDependencies: { '@tauri-apps/cli': '^2.11' } } });
390
+ const calls = [];
391
+
392
+ await initNative(dir, { port: 3123 }, { run: stubbedRun(calls) });
393
+
394
+ const addDevCalls = calls.filter((c) => c.args.includes('@tauri-apps/cli@^2') || c.cmd === 'pnpm' && c.args[0] === 'add');
395
+ expect(addDevCalls).toHaveLength(0);
396
+ });
397
+
398
+ it('does not clobber an existing native:dev script', async () => {
399
+ const dir = makeTempDir();
400
+ scaffoldApp(dir, { pkg: { scripts: { 'native:dev': 'echo custom' } } });
401
+ const calls = [];
402
+
403
+ await initNative(dir, { port: 3123 }, { run: stubbedRun(calls) });
404
+
405
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
406
+ expect(pkg.scripts['native:dev']).toBe('echo custom');
407
+ expect(pkg.scripts['native:build']).toBe('tauri build');
408
+ });
409
+
410
+ it('runs tauri icon when --icon is given', async () => {
411
+ const dir = makeTempDir();
412
+ scaffoldApp(dir);
413
+ const calls = [];
414
+
415
+ await initNative(dir, { port: 3123, icon: 'icon.png' }, { run: stubbedRun(calls) });
416
+
417
+ const iconCall = calls.find((c) => c.args[0] === 'tauri' && c.args[1] === 'icon');
418
+ expect(iconCall).toBeTruthy();
419
+ expect(iconCall.args).toEqual(['tauri', 'icon', 'icon.png']);
420
+ });
421
+
422
+ it('does not run tauri icon when --icon is not given', async () => {
423
+ const dir = makeTempDir();
424
+ scaffoldApp(dir);
425
+ const calls = [];
426
+
427
+ await initNative(dir, { port: 3123 }, { run: stubbedRun(calls) });
428
+
429
+ expect(calls.find((c) => c.args[1] === 'icon')).toBeUndefined();
430
+ });
431
+
432
+ it('rejects an invalid --identifier', async () => {
433
+ const dir = makeTempDir();
434
+ scaffoldApp(dir);
435
+ const calls = [];
436
+
437
+ await expect(initNative(dir, { identifier: 'com.tauri.dev' }, { run: stubbedRun(calls) })).rejects.toThrow();
438
+ await expect(initNative(dir, { identifier: 'not valid' }, { run: stubbedRun(calls) })).rejects.toThrow();
439
+ });
440
+ });
441
+
442
+ describe('TAURI_PLUGIN_PERMISSIONS', () => {
443
+ it('is a frozen array of the three window permissions', () => {
444
+ expect(Object.isFrozen(TAURI_PLUGIN_PERMISSIONS)).toBe(true);
445
+ expect(TAURI_PLUGIN_PERMISSIONS).toEqual([
446
+ 'core:window:allow-set-fullscreen',
447
+ 'core:window:allow-is-fullscreen',
448
+ 'core:window:allow-close',
449
+ ]);
450
+ });
451
+ });
452
+
453
+ describe('patchCapabilities', () => {
454
+ it('appends missing permissions, keeping existing order', () => {
455
+ const capabilities = { permissions: ['core:default', 'core:window:allow-set-fullscreen'] };
456
+
457
+ const patched = patchCapabilities(capabilities, TAURI_PLUGIN_PERMISSIONS);
458
+
459
+ expect(patched.permissions).toEqual([
460
+ 'core:default',
461
+ 'core:window:allow-set-fullscreen',
462
+ 'core:window:allow-is-fullscreen',
463
+ 'core:window:allow-close',
464
+ ]);
465
+ });
466
+
467
+ it('does not duplicate permissions already present', () => {
468
+ const capabilities = { permissions: [...TAURI_PLUGIN_PERMISSIONS] };
469
+
470
+ const patched = patchCapabilities(capabilities, TAURI_PLUGIN_PERMISSIONS);
471
+
472
+ expect(patched.permissions).toEqual([...TAURI_PLUGIN_PERMISSIONS]);
473
+ });
474
+
475
+ it('preserves object-form permission entries untouched', () => {
476
+ const objectEntry = { identifier: 'core:window:allow-set-title' };
477
+ const capabilities = { permissions: [objectEntry] };
478
+
479
+ const patched = patchCapabilities(capabilities, TAURI_PLUGIN_PERMISSIONS);
480
+
481
+ expect(patched.permissions[0]).toBe(objectEntry);
482
+ expect(patched.permissions).toEqual([objectEntry, ...TAURI_PLUGIN_PERMISSIONS]);
483
+ });
484
+
485
+ it('creates the permissions array when absent', () => {
486
+ const capabilities = { identifier: 'default' };
487
+
488
+ const patched = patchCapabilities(capabilities, TAURI_PLUGIN_PERMISSIONS);
489
+
490
+ expect(patched.permissions).toEqual([...TAURI_PLUGIN_PERMISSIONS]);
491
+ });
492
+
493
+ it('does not mutate the original object', () => {
494
+ const capabilities = { permissions: ['core:default'] };
495
+
496
+ patchCapabilities(capabilities, TAURI_PLUGIN_PERMISSIONS);
497
+
498
+ expect(capabilities.permissions).toEqual(['core:default']);
499
+ });
500
+ });
501
+
502
+ describe('missingDeps', () => {
503
+ it('returns names absent from both dependencies and devDependencies', () => {
504
+ const pkg = { dependencies: { a: '1' }, devDependencies: { b: '1' } };
505
+ expect(missingDeps(pkg, ['a', 'b', 'c'])).toEqual(['c']);
506
+ });
507
+
508
+ it('returns everything when there are no deps at all', () => {
509
+ expect(missingDeps({}, ['a', 'b'])).toEqual(['a', 'b']);
510
+ });
511
+ });
512
+
513
+ describe('addNativePlugin', () => {
514
+ function scaffoldNativePlugin(dir, { pkg = {}, cargoToml = '[package]\nname = "my-game"\n', capabilities } = {}) {
515
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '', 'utf-8');
516
+ fs.writeFileSync(
517
+ path.join(dir, 'package.json'),
518
+ JSON.stringify({ name: 'my-game', version: '1.0.0', ...pkg }, null, 2) + '\n',
519
+ 'utf-8',
520
+ );
521
+ fs.mkdirSync(path.join(dir, 'src-tauri/capabilities'), { recursive: true });
522
+ fs.writeFileSync(path.join(dir, 'src-tauri/tauri.conf.json'), JSON.stringify({ identifier: 'dev.caper.my-game' }, null, 2) + '\n', 'utf-8');
523
+ fs.writeFileSync(path.join(dir, 'src-tauri/Cargo.toml'), cargoToml, 'utf-8');
524
+ fs.writeFileSync(
525
+ path.join(dir, 'src-tauri/capabilities/default.json'),
526
+ JSON.stringify(
527
+ capabilities ?? {
528
+ $schema: '../gen/schemas/desktop-schema.json',
529
+ identifier: 'default',
530
+ description: 'Capability for the main window',
531
+ windows: ['main'],
532
+ permissions: ['core:default'],
533
+ },
534
+ null,
535
+ 2,
536
+ ) + '\n',
537
+ 'utf-8',
538
+ );
539
+ }
540
+
541
+ // Mirrors what `pnpm add <pkg>` / `tauri add store` actually do to the
542
+ // filesystem, so a *second* call sees the real post-install state.
543
+ function stubbedPluginRun(calls) {
544
+ return (cmd, args, opts) => {
545
+ calls.push({ cmd, args, opts });
546
+ if (args[0] === 'tauri' && args[1] === 'add' && args[2] === 'store') {
547
+ const cargoTomlPath = path.join(opts.cwd, 'src-tauri/Cargo.toml');
548
+ const existing = fs.readFileSync(cargoTomlPath, 'utf-8');
549
+ fs.writeFileSync(cargoTomlPath, `${existing}\n[dependencies]\ntauri-plugin-store = "2"\n`, 'utf-8');
550
+ } else if (args[0] === 'add' || args[0] === 'install') {
551
+ const pkgPath = path.join(opts.cwd, 'package.json');
552
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
553
+ pkg.dependencies = pkg.dependencies ?? {};
554
+ for (const spec of args.slice(1)) {
555
+ const at = spec.lastIndexOf('@');
556
+ const name = at > 0 ? spec.slice(0, at) : spec;
557
+ const version = at > 0 ? spec.slice(at + 1) : 'latest';
558
+ pkg.dependencies[name] = version;
559
+ }
560
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
561
+ }
562
+ return { status: 0 };
563
+ };
564
+ }
565
+
566
+ it('runs the full first-time flow: adds deps, runs tauri add store, and patches capabilities', async () => {
567
+ const dir = makeTempDir();
568
+ scaffoldNativePlugin(dir);
569
+ const calls = [];
570
+
571
+ const result = await addNativePlugin(dir, {}, { run: stubbedPluginRun(calls) });
572
+
573
+ expect(result.status).toBe('ok');
574
+ expect(result.changed).toBe(true);
575
+ expect(result.addedDeps).toEqual(['@caperjs/plugin-tauri', '@tauri-apps/api']);
576
+ expect(result.ranTauriAddStore).toBe(true);
577
+ expect(result.capabilitiesChanged).toBe(true);
578
+ expect(calls).toHaveLength(2);
579
+
580
+ const addCall = calls.find((c) => c.args[0] === 'add');
581
+ expect(addCall.cmd).toBe('pnpm');
582
+ expect(addCall.args).toEqual(['add', '@caperjs/plugin-tauri', '@tauri-apps/api@^2']);
583
+
584
+ const storeCall = calls.find((c) => c.args[0] === 'tauri');
585
+ expect(storeCall.cmd).toBe('pnpm');
586
+ expect(storeCall.args).toEqual(['tauri', 'add', 'store']);
587
+
588
+ const capabilities = JSON.parse(fs.readFileSync(path.join(dir, 'src-tauri/capabilities/default.json'), 'utf-8'));
589
+ expect(capabilities.permissions).toEqual(['core:default', ...TAURI_PLUGIN_PERMISSIONS]);
590
+ });
591
+
592
+ it('is idempotent: a second run makes zero run calls and leaves files unchanged', async () => {
593
+ const dir = makeTempDir();
594
+ scaffoldNativePlugin(dir);
595
+
596
+ await addNativePlugin(dir, {}, { run: stubbedPluginRun([]) });
597
+
598
+ const pkgAfterFirst = fs.readFileSync(path.join(dir, 'package.json'), 'utf-8');
599
+ const cargoAfterFirst = fs.readFileSync(path.join(dir, 'src-tauri/Cargo.toml'), 'utf-8');
600
+ const capabilitiesAfterFirst = fs.readFileSync(path.join(dir, 'src-tauri/capabilities/default.json'), 'utf-8');
601
+
602
+ const secondCalls = [];
603
+ const result = await addNativePlugin(dir, {}, { run: stubbedPluginRun(secondCalls) });
604
+
605
+ expect(secondCalls).toHaveLength(0);
606
+ expect(result.changed).toBe(false);
607
+ expect(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')).toBe(pkgAfterFirst);
608
+ expect(fs.readFileSync(path.join(dir, 'src-tauri/Cargo.toml'), 'utf-8')).toBe(cargoAfterFirst);
609
+ expect(fs.readFileSync(path.join(dir, 'src-tauri/capabilities/default.json'), 'utf-8')).toBe(capabilitiesAfterFirst);
610
+ });
611
+
612
+ it('fails clearly when src-tauri does not exist, and writes nothing', async () => {
613
+ const dir = makeTempDir();
614
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'my-game' }, null, 2) + '\n', 'utf-8');
615
+ const calls = [];
616
+
617
+ await expect(addNativePlugin(dir, {}, { run: stubbedPluginRun(calls) })).rejects.toThrow(/caper native init/);
618
+ expect(calls).toHaveLength(0);
619
+ expect(fs.existsSync(path.join(dir, 'src-tauri'))).toBe(false);
620
+ });
621
+
622
+ it('fails clearly when capabilities/default.json is missing', async () => {
623
+ const dir = makeTempDir();
624
+ scaffoldNativePlugin(dir);
625
+ fs.rmSync(path.join(dir, 'src-tauri/capabilities/default.json'));
626
+
627
+ await expect(addNativePlugin(dir, {}, { run: stubbedPluginRun([]) })).rejects.toThrow(/capabilities\/default\.json/);
628
+ });
629
+
630
+ it('fails clearly when capabilities/default.json is not valid JSON', async () => {
631
+ const dir = makeTempDir();
632
+ scaffoldNativePlugin(dir);
633
+ fs.writeFileSync(path.join(dir, 'src-tauri/capabilities/default.json'), '{ not json', 'utf-8');
634
+
635
+ await expect(addNativePlugin(dir, {}, { run: stubbedPluginRun([]) })).rejects.toThrow(/capabilities\/default\.json/);
636
+ });
637
+
638
+ it('skips the dep-add call when both deps are already present', async () => {
639
+ const dir = makeTempDir();
640
+ scaffoldNativePlugin(dir, { pkg: { dependencies: { '@caperjs/plugin-tauri': '^1', '@tauri-apps/api': '^2' } } });
641
+ const calls = [];
642
+
643
+ const result = await addNativePlugin(dir, {}, { run: stubbedPluginRun(calls) });
644
+
645
+ expect(result.addedDeps).toEqual([]);
646
+ expect(calls.find((c) => c.args[0] === 'add' || c.args[0] === 'install')).toBeUndefined();
647
+ });
648
+
649
+ it('skips tauri add store when Cargo.toml already lists tauri-plugin-store', async () => {
650
+ const dir = makeTempDir();
651
+ scaffoldNativePlugin(dir, { cargoToml: '[dependencies]\ntauri-plugin-store = "2"\n' });
652
+ const calls = [];
653
+
654
+ const result = await addNativePlugin(dir, {}, { run: stubbedPluginRun(calls) });
655
+
656
+ expect(result.ranTauriAddStore).toBe(false);
657
+ expect(calls.find((c) => c.args[0] === 'tauri')).toBeUndefined();
658
+ });
659
+ });
660
+
661
+ const STOCK_MAIN_ACTIVITY =
662
+ 'package x.y.z\n\nimport android.os.Bundle\nimport androidx.activity.enableEdgeToEdge\n\nclass MainActivity : TauriActivity() {\n override fun onCreate(savedInstanceState: Bundle?) {\n enableEdgeToEdge()\n super.onCreate(savedInstanceState)\n }\n}\n';
663
+ const STOCK_BUILD_RS = 'fn main() {\n tauri_build::build()\n}\n';
664
+
665
+ describe('resolveAndroidEnv', () => {
666
+ const HOME = '/Users/me';
667
+ const MAC_SDK = '/Users/me/Library/Android/sdk';
668
+ const JBR = '/Applications/Android Studio.app/Contents/jbr/Contents/Home';
669
+
670
+ function fakeFs(paths, dirs = {}) {
671
+ const set = new Set(paths);
672
+ return {
673
+ exists: (p) => set.has(p),
674
+ listDir: (p) => dirs[p] ?? [],
675
+ };
676
+ }
677
+
678
+ it('uses explicit env values as-is', () => {
679
+ const { env, missing } = resolveAndroidEnv({
680
+ env: { ANDROID_HOME: '/sdk', NDK_HOME: '/ndk', JAVA_HOME: '/java', PATH: '/rup/bin:/usr/bin' },
681
+ platform: 'linux',
682
+ homedir: HOME,
683
+ ...fakeFs(['/rup/bin/rustup']),
684
+ });
685
+ expect(env.ANDROID_HOME).toBe('/sdk');
686
+ expect(env.NDK_HOME).toBe('/ndk');
687
+ expect(env.JAVA_HOME).toBe('/java');
688
+ expect(env.PATH).toBe('/rup/bin:/usr/bin');
689
+ expect(missing).toEqual([]);
690
+ });
691
+
692
+ it('falls back to ANDROID_SDK_ROOT for ANDROID_HOME', () => {
693
+ const { env } = resolveAndroidEnv({ env: { ANDROID_SDK_ROOT: '/root-sdk', PATH: '' }, platform: 'linux', homedir: HOME, ...fakeFs([]) });
694
+ expect(env.ANDROID_HOME).toBe('/root-sdk');
695
+ });
696
+
697
+ it('finds the macOS SDK, the highest NDK (numerically), and Android Studio JBR on darwin', () => {
698
+ const { env, missing } = resolveAndroidEnv({
699
+ env: { PATH: '/rup/bin' },
700
+ platform: 'darwin',
701
+ homedir: HOME,
702
+ ...fakeFs([MAC_SDK, JBR, '/rup/bin/rustup'], {
703
+ [`${MAC_SDK}/ndk`]: ['27.0.12077973', '27.1.12297006', '9.9.9', '.DS_Store'],
704
+ }),
705
+ });
706
+ expect(env.ANDROID_HOME).toBe(MAC_SDK);
707
+ expect(env.NDK_HOME).toBe(`${MAC_SDK}/ndk/27.1.12297006`);
708
+ expect(env.JAVA_HOME).toBe(JBR);
709
+ expect(missing).toEqual([]);
710
+ });
711
+
712
+ it('does not probe macOS paths on other platforms and reports what is missing', () => {
713
+ const { env, missing } = resolveAndroidEnv({
714
+ env: { PATH: '/usr/bin' },
715
+ platform: 'linux',
716
+ homedir: HOME,
717
+ ...fakeFs([MAC_SDK, JBR]),
718
+ });
719
+ expect(env.ANDROID_HOME).toBeUndefined();
720
+ expect(missing).toEqual(expect.arrayContaining(['ANDROID_HOME', 'NDK_HOME', 'JAVA_HOME', 'rustup']));
721
+ });
722
+
723
+ it('prepends keg-only Homebrew rustup when rustup is not on PATH', () => {
724
+ const { env, missing } = resolveAndroidEnv({
725
+ env: { PATH: '/usr/bin' },
726
+ platform: 'darwin',
727
+ homedir: HOME,
728
+ ...fakeFs(['/opt/homebrew/opt/rustup/bin/rustup', `${HOME}/.cargo/bin/rustup`]),
729
+ });
730
+ expect(env.PATH).toBe('/opt/homebrew/opt/rustup/bin:/usr/bin');
731
+ expect(missing).not.toContain('rustup');
732
+ });
733
+
734
+ it('falls back to ~/.cargo/bin when there is no Homebrew rustup', () => {
735
+ const { env } = resolveAndroidEnv({
736
+ env: { PATH: '/usr/bin' },
737
+ platform: 'linux',
738
+ homedir: HOME,
739
+ ...fakeFs([`${HOME}/.cargo/bin/rustup`]),
740
+ });
741
+ expect(env.PATH).toBe(`${HOME}/.cargo/bin:/usr/bin`);
742
+ });
743
+
744
+ it("prepends rustup's proxies when Homebrew's rustc would shadow them", () => {
745
+ const { env } = resolveAndroidEnv({
746
+ env: { PATH: `/opt/homebrew/bin:${HOME}/.cargo/bin` },
747
+ platform: 'darwin',
748
+ homedir: HOME,
749
+ ...fakeFs(['/opt/homebrew/bin/rustc', `${HOME}/.cargo/bin/rustup`, `${HOME}/.cargo/bin/rustc`]),
750
+ });
751
+ expect(env.PATH).toBe(`${HOME}/.cargo/bin:/opt/homebrew/bin:${HOME}/.cargo/bin`);
752
+ });
753
+
754
+ it("prepends keg-only rustup when Homebrew links rustup next to its own rust's rustc", () => {
755
+ const { env } = resolveAndroidEnv({
756
+ env: { PATH: '/opt/homebrew/bin:/usr/bin' },
757
+ platform: 'darwin',
758
+ homedir: HOME,
759
+ ...fakeFs(['/opt/homebrew/bin/rustc', '/opt/homebrew/bin/rustup', '/opt/homebrew/opt/rustup/bin/rustup']),
760
+ });
761
+ expect(env.PATH).toBe('/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:/usr/bin');
762
+ });
763
+
764
+ it('leaves PATH alone when rustup already comes first', () => {
765
+ const { env } = resolveAndroidEnv({
766
+ env: { PATH: `${HOME}/.cargo/bin:/opt/homebrew/bin` },
767
+ platform: 'darwin',
768
+ homedir: HOME,
769
+ ...fakeFs(['/opt/homebrew/bin/rustc', `${HOME}/.cargo/bin/rustup`, `${HOME}/.cargo/bin/rustc`, '/opt/homebrew/opt/rustup/bin/rustup']),
770
+ });
771
+ expect(env.PATH).toBe(`${HOME}/.cargo/bin:/opt/homebrew/bin`);
772
+ });
773
+
774
+ it('keeps unrelated env vars', () => {
775
+ const { env } = resolveAndroidEnv({ env: { FOO: 'bar', PATH: '' }, platform: 'linux', homedir: HOME, ...fakeFs([]) });
776
+ expect(env.FOO).toBe('bar');
777
+ });
778
+ });
779
+
780
+ describe('missingRustTargets', () => {
781
+ it('lists the four Android targets', () => {
782
+ expect(ANDROID_RUST_TARGETS).toEqual(['aarch64-linux-android', 'armv7-linux-androideabi', 'i686-linux-android', 'x86_64-linux-android']);
783
+ expect(Object.isFrozen(ANDROID_RUST_TARGETS)).toBe(true);
784
+ });
785
+
786
+ it('returns the targets absent from `rustup target list --installed` output', () => {
787
+ const out = 'aarch64-apple-darwin\naarch64-linux-android\nx86_64-linux-android\n';
788
+ expect(missingRustTargets(out)).toEqual(['armv7-linux-androideabi', 'i686-linux-android']);
789
+ });
790
+
791
+ it('returns nothing when all are installed', () => {
792
+ expect(missingRustTargets(ANDROID_RUST_TARGETS.join('\n'))).toEqual([]);
793
+ });
794
+ });
795
+
796
+ describe('planBuildRs', () => {
797
+ it('writes when build.rs is missing', () => {
798
+ expect(planBuildRs(null)).toBe('write');
799
+ });
800
+
801
+ it("writes over Tauri's stock build.rs, whitespace-insensitively", () => {
802
+ expect(planBuildRs(STOCK_BUILD_RS)).toBe('write');
803
+ expect(planBuildRs('fn main(){tauri_build::build()}')).toBe('write');
804
+ });
805
+
806
+ it('skips when the 16 KB link arg is already there', () => {
807
+ expect(planBuildRs('fn main() { println!("cargo:rustc-link-arg=-Wl,-z,max-page-size=16384"); tauri_build::build() }')).toBe('skip');
808
+ });
809
+
810
+ it('warns on a customized build.rs', () => {
811
+ expect(planBuildRs('fn main() {\n do_something();\n tauri_build::build()\n}\n')).toBe('warn');
812
+ });
813
+ });
814
+
815
+ describe('planMainActivity / renderMainActivity', () => {
816
+ it("replaces Tauri's stock template", () => {
817
+ expect(planMainActivity(STOCK_MAIN_ACTIVITY)).toBe('write');
818
+ });
819
+
820
+ it('replaces an empty-body stock class', () => {
821
+ expect(planMainActivity('package a.b\n\nclass MainActivity : TauriActivity()\n')).toBe('write');
822
+ expect(planMainActivity('package a.b\n\nclass MainActivity : TauriActivity() {}\n')).toBe('write');
823
+ });
824
+
825
+ it('skips when the system bars are already hidden', () => {
826
+ expect(planMainActivity(renderMainActivity('package x.y.z'))).toBe('skip');
827
+ });
828
+
829
+ it('warns on a customized MainActivity', () => {
830
+ const custom = STOCK_MAIN_ACTIVITY.replace('super.onCreate(savedInstanceState)', 'super.onCreate(savedInstanceState)\n doMore()');
831
+ expect(planMainActivity(custom)).toBe('warn');
832
+ });
833
+
834
+ it("keeps the original package line and hides the bars in onCreate and onWindowFocusChanged", () => {
835
+ const out = renderMainActivity('package dev.caper.my_game');
836
+ expect(out.startsWith('package dev.caper.my_game\n')).toBe(true);
837
+ expect(out).toContain('WindowInsetsCompat.Type.systemBars()');
838
+ expect(out).toContain('BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE');
839
+ expect(out).toContain('override fun onWindowFocusChanged');
840
+ });
841
+ });
842
+
843
+ describe('patchAndroidScripts', () => {
844
+ it('adds the android scripts when absent', () => {
845
+ expect(patchAndroidScripts({ scripts: { dev: 'vite' } }).scripts).toEqual({
846
+ dev: 'vite',
847
+ 'native:android:dev': 'tauri android dev',
848
+ 'native:android:build': 'tauri android build',
849
+ });
850
+ });
851
+
852
+ it('never overwrites existing ones and does not mutate', () => {
853
+ const pkg = { scripts: { 'native:android:dev': 'custom' } };
854
+ const out = patchAndroidScripts(pkg);
855
+ expect(out.scripts['native:android:dev']).toBe('custom');
856
+ expect(out.scripts['native:android:build']).toBe('tauri android build');
857
+ expect(pkg.scripts).toEqual({ 'native:android:dev': 'custom' });
858
+ });
859
+ });
860
+
861
+ describe('androidNative', () => {
862
+ function scaffoldAndroidApp(dir) {
863
+ fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), '', 'utf-8');
864
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'my-game', scripts: { dev: 'vite' } }, null, 4) + '\n', 'utf-8');
865
+ fs.mkdirSync(path.join(dir, 'src-tauri'), { recursive: true });
866
+ fs.writeFileSync(path.join(dir, 'src-tauri/tauri.conf.json'), JSON.stringify({ identifier: 'dev.caper.mygame' }, null, 2) + '\n', 'utf-8');
867
+ fs.writeFileSync(path.join(dir, 'src-tauri/build.rs'), STOCK_BUILD_RS, 'utf-8');
868
+ const bin = path.join(dir, 'bin');
869
+ fs.mkdirSync(bin);
870
+ fs.writeFileSync(path.join(bin, 'rustup'), '', 'utf-8');
871
+ return {
872
+ env: { PATH: bin, ANDROID_HOME: path.join(dir, 'sdk'), NDK_HOME: path.join(dir, 'ndk'), JAVA_HOME: path.join(dir, 'java') },
873
+ platform: 'linux',
874
+ homedir: path.join(dir, 'home'),
875
+ };
876
+ }
877
+
878
+ // Mirrors what `rustup target add` / `tauri android init` do to the machine.
879
+ function stubs(state, calls) {
880
+ return {
881
+ exec: (cmd, args, opts) => {
882
+ calls.push({ kind: 'exec', cmd, args, opts });
883
+ if (cmd === 'rustup' && args.join(' ') === 'target list --installed') return state.installed.join('\n') + '\n';
884
+ throw new Error(`unstubbed exec: ${cmd} ${args.join(' ')}`);
885
+ },
886
+ run: (cmd, args, opts) => {
887
+ calls.push({ kind: 'run', cmd, args, opts });
888
+ if (cmd === 'rustup' && args[0] === 'target' && args[1] === 'add') {
889
+ state.installed.push(...args.slice(2));
890
+ } else if (cmd === 'pnpm' && args.join(' ') === 'tauri android init --ci') {
891
+ const dir = path.join(opts.cwd, 'src-tauri/gen/android/app/src/main/java/x/y/z');
892
+ fs.mkdirSync(dir, { recursive: true });
893
+ fs.writeFileSync(path.join(dir, 'MainActivity.kt'), STOCK_MAIN_ACTIVITY, 'utf-8');
894
+ } else {
895
+ throw new Error(`unstubbed run: ${cmd} ${args.join(' ')}`);
896
+ }
897
+ return { status: 0 };
898
+ },
899
+ };
900
+ }
901
+
902
+ function snapshot(dir) {
903
+ const out = {};
904
+ const walk = (p) => {
905
+ for (const entry of fs.readdirSync(p, { withFileTypes: true })) {
906
+ const full = path.join(p, entry.name);
907
+ if (entry.isDirectory()) walk(full);
908
+ else out[path.relative(dir, full)] = fs.readFileSync(full, 'utf-8') + String(fs.statSync(full).mtimeMs);
909
+ }
910
+ };
911
+ walk(dir);
912
+ return out;
913
+ }
914
+
915
+ it('fails clearly when src-tauri does not exist, with zero calls', async () => {
916
+ const dir = makeTempDir();
917
+ fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"x"}\n', 'utf-8');
918
+ const calls = [];
919
+ await expect(androidNative(dir, {}, { ...stubs({ installed: [] }, calls), env: {}, platform: 'linux', homedir: dir })).rejects.toThrow(
920
+ /caper native init/,
921
+ );
922
+ expect(calls).toHaveLength(0);
923
+ });
924
+
925
+ it('fails in plain English when NDK_HOME, JAVA_HOME or rustup cannot be found, before running anything', async () => {
926
+ const dir = makeTempDir();
927
+ const deps = scaffoldAndroidApp(dir);
928
+ const calls = [];
929
+ await expect(
930
+ androidNative(dir, {}, { ...stubs({ installed: [] }, calls), env: { PATH: '/nowhere' }, platform: 'linux', homedir: path.join(dir, 'home'), exists: () => false }),
931
+ ).rejects.toThrow(/NDK_HOME[\s\S]*JAVA_HOME[\s\S]*rustup/);
932
+ expect(calls).toHaveLength(0);
933
+ expect(deps).toBeTruthy();
934
+ });
935
+
936
+ it('runs the full first-time flow with the resolved env passed to every child process', async () => {
937
+ const dir = makeTempDir();
938
+ const deps = scaffoldAndroidApp(dir);
939
+ const calls = [];
940
+ const state = { installed: ['aarch64-apple-darwin', 'aarch64-linux-android'] };
941
+
942
+ const result = await androidNative(dir, {}, { ...stubs(state, calls), ...deps });
943
+
944
+ expect(result.status).toBe('ok');
945
+ expect(result.changed).toBe(true);
946
+ expect(result.addedTargets).toEqual(['armv7-linux-androideabi', 'i686-linux-android', 'x86_64-linux-android']);
947
+ expect(result.ranAndroidInit).toBe(true);
948
+ expect(result.buildRsPatched).toBe(true);
949
+ expect(result.mainActivityPatched).toBe(true);
950
+ expect(result.addedScripts).toEqual(['native:android:dev', 'native:android:build']);
951
+ expect(result.warnings).toEqual([]);
952
+ expect(result.env).toEqual({ ANDROID_HOME: deps.env.ANDROID_HOME, NDK_HOME: deps.env.NDK_HOME, JAVA_HOME: deps.env.JAVA_HOME });
953
+
954
+ const runs = calls.filter((c) => c.kind === 'run');
955
+ expect(runs.map((c) => `${c.cmd} ${c.args.join(' ')}`)).toEqual([
956
+ 'rustup target add armv7-linux-androideabi i686-linux-android x86_64-linux-android',
957
+ 'pnpm tauri android init --ci',
958
+ ]);
959
+ for (const call of calls) {
960
+ expect(call.opts.cwd).toBe(dir);
961
+ expect(call.opts.env.NDK_HOME).toBe(deps.env.NDK_HOME);
962
+ expect(call.opts.env.JAVA_HOME).toBe(deps.env.JAVA_HOME);
963
+ expect(call.opts.env.PATH).toBe(deps.env.PATH);
964
+ }
965
+
966
+ expect(fs.readFileSync(path.join(dir, 'src-tauri/build.rs'), 'utf-8')).toContain('max-page-size=16384');
967
+ const activity = fs.readFileSync(path.join(dir, 'src-tauri/gen/android/app/src/main/java/x/y/z/MainActivity.kt'), 'utf-8');
968
+ expect(activity.startsWith('package x.y.z\n')).toBe(true);
969
+ expect(activity).toContain('WindowInsetsCompat.Type.systemBars()');
970
+
971
+ const pkgRaw = fs.readFileSync(path.join(dir, 'package.json'), 'utf-8');
972
+ expect(pkgRaw).toContain('\n "scripts"');
973
+ expect(JSON.parse(pkgRaw).scripts['native:android:dev']).toBe('tauri android dev');
974
+ });
975
+
976
+ it('is idempotent: a second run makes zero run calls and zero writes', async () => {
977
+ const dir = makeTempDir();
978
+ const deps = scaffoldAndroidApp(dir);
979
+ const state = { installed: [] };
980
+ await androidNative(dir, {}, { ...stubs(state, []), ...deps });
981
+ const before = snapshot(dir);
982
+
983
+ const calls = [];
984
+ const result = await androidNative(dir, {}, { ...stubs(state, calls), ...deps });
985
+
986
+ expect(calls.filter((c) => c.kind === 'run')).toHaveLength(0);
987
+ expect(result.changed).toBe(false);
988
+ expect(result.warnings).toEqual([]);
989
+ expect(snapshot(dir)).toEqual(before);
990
+ });
991
+
992
+ it('writes the build.rs template when build.rs is missing', async () => {
993
+ const dir = makeTempDir();
994
+ const deps = scaffoldAndroidApp(dir);
995
+ fs.rmSync(path.join(dir, 'src-tauri/build.rs'));
996
+ const result = await androidNative(dir, {}, { ...stubs({ installed: [...ANDROID_RUST_TARGETS] }, []), ...deps });
997
+ expect(result.buildRsPatched).toBe(true);
998
+ expect(fs.readFileSync(path.join(dir, 'src-tauri/build.rs'), 'utf-8')).toContain('tauri_build::build()');
999
+ });
1000
+
1001
+ it('warns and leaves a customized build.rs and MainActivity untouched', async () => {
1002
+ const dir = makeTempDir();
1003
+ const deps = scaffoldAndroidApp(dir);
1004
+ const customRs = 'fn main() {\n custom();\n tauri_build::build()\n}\n';
1005
+ fs.writeFileSync(path.join(dir, 'src-tauri/build.rs'), customRs, 'utf-8');
1006
+ const activityDir = path.join(dir, 'src-tauri/gen/android/app/src/main/java/a/b');
1007
+ fs.mkdirSync(activityDir, { recursive: true });
1008
+ const customKt = 'package a.b\n\nclass MainActivity : TauriActivity() {\n fun other() {}\n}\n';
1009
+ fs.writeFileSync(path.join(activityDir, 'MainActivity.kt'), customKt, 'utf-8');
1010
+ const calls = [];
1011
+
1012
+ const result = await androidNative(dir, {}, { ...stubs({ installed: [...ANDROID_RUST_TARGETS] }, calls), ...deps });
1013
+
1014
+ expect(result.ranAndroidInit).toBe(false);
1015
+ expect(result.buildRsPatched).toBe(false);
1016
+ expect(result.mainActivityPatched).toBe(false);
1017
+ expect(result.warnings).toHaveLength(2);
1018
+ expect(result.warnings[0]).toMatch(/build\.rs[\s\S]*max-page-size=16384/);
1019
+ expect(result.warnings[1]).toMatch(/MainActivity\.kt/);
1020
+ expect(fs.readFileSync(path.join(dir, 'src-tauri/build.rs'), 'utf-8')).toBe(customRs);
1021
+ expect(fs.readFileSync(path.join(activityDir, 'MainActivity.kt'), 'utf-8')).toBe(customKt);
1022
+ expect(calls.filter((c) => c.kind === 'run')).toHaveLength(0);
1023
+ });
1024
+ });
1025
+
1026
+ describe('findMainActivity', () => {
1027
+ it('finds MainActivity.kt anywhere under the java source root', () => {
1028
+ const dir = makeTempDir();
1029
+ const target = path.join(dir, 'gen/android/app/src/main/java/dev/caper/my_game/MainActivity.kt');
1030
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1031
+ fs.writeFileSync(target, '', 'utf-8');
1032
+ expect(findMainActivity(path.join(dir, 'gen/android'))).toBe(target);
1033
+ });
1034
+
1035
+ it('returns null when there is none', () => {
1036
+ const dir = makeTempDir();
1037
+ expect(findMainActivity(path.join(dir, 'gen/android'))).toBeNull();
1038
+ });
1039
+ });