@octane-xplat/files 0.10.0 → 0.11.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,342 @@
1
+ // Native-leaf bridge tests. The @nativescript/* module imports and the
2
+ // iOS/Android global APIs are replaced with faithful mock shapes (same call
3
+ // surfaces the runtime marshals) so the leaf's failure, concurrency, and
4
+ // cleanup paths are exercised in node — not a substitute for native smoke.
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ const bridge = vi.hoisted(() => ({
8
+ application: { android: undefined as any },
9
+ listeners: new Map<string, Set<(arg: any) => void>>(),
10
+ resolver: null as any,
11
+ activity: null as any,
12
+ visibleVC: null as any,
13
+ keyWindow: true,
14
+ writeAtomic: true,
15
+ removedPaths: [] as string[],
16
+ channelWrites: [] as any[],
17
+ presented: [] as any[],
18
+ }))
19
+
20
+ vi.mock('@nativescript-community/ui-document-picker', () => ({
21
+ openFilePicker: vi.fn(async () => ({ files: [] })),
22
+ }))
23
+
24
+ vi.mock('@nativescript/core', () => ({
25
+ Application: bridge.application,
26
+ File: {
27
+ fromPath: (p: string) => ({
28
+ removeSync: () => {
29
+ bridge.removedPaths.push(p)
30
+ },
31
+ readText: async () => '',
32
+ }),
33
+ },
34
+ knownFolders: {
35
+ documents: () => ({ path: '/docs' }),
36
+ temp: () => ({ path: '/tmp' }),
37
+ },
38
+ path: {
39
+ join: (...parts: string[]) => parts.join('/'),
40
+ },
41
+ Utils: {
42
+ ios: {
43
+ getVisibleViewController: (root: any) => (root ? bridge.visibleVC : null),
44
+ },
45
+ },
46
+ }))
47
+
48
+ // Explicit .ts specifier: the web resolve chain would otherwise pick
49
+ // files.web.ts; this suite covers the shared iOS/Android leaf.
50
+ import { files } from './files.ts'
51
+ import { files as macosFiles } from './files.macos'
52
+
53
+ function stubAndroid() {
54
+ bridge.resolver = { openOutputStream: vi.fn(() => ({ close: vi.fn() })) }
55
+ bridge.activity = {
56
+ getContentResolver: () => bridge.resolver,
57
+ startActivityForResult: vi.fn(),
58
+ }
59
+
60
+ bridge.application.android = {
61
+ activityResultEvent: 'activityResult',
62
+ foregroundActivity: bridge.activity,
63
+ startActivity: bridge.activity,
64
+ on(event: string, cb: (arg: any) => void) {
65
+ if (!bridge.listeners.has(event)) {
66
+ bridge.listeners.set(event, new Set())
67
+ }
68
+
69
+ bridge.listeners.get(event)!.add(cb)
70
+ },
71
+ off(event: string, cb: (arg: any) => void) {
72
+ bridge.listeners.get(event)?.delete(cb)
73
+ },
74
+ }
75
+ }
76
+
77
+ function emitResult(arg: any) {
78
+ for (const cb of Array.from(bridge.listeners.get('activityResult') ?? [])) {
79
+ cb(arg)
80
+ }
81
+ }
82
+
83
+ function okResult(uri = 'content://dest/doc.bin') {
84
+ return {
85
+ requestCode: 1241,
86
+ resultCode: -1,
87
+ intent: { getData: () => ({ toString: () => uri }) },
88
+ }
89
+ }
90
+
91
+ let lastPicker: any
92
+
93
+ beforeEach(() => {
94
+ bridge.listeners.clear()
95
+ bridge.removedPaths = []
96
+ bridge.channelWrites = []
97
+ bridge.presented = []
98
+ bridge.keyWindow = true
99
+ bridge.writeAtomic = true
100
+ bridge.resolver = null
101
+ bridge.activity = null
102
+ lastPicker = null
103
+
104
+ bridge.visibleVC = {
105
+ presentViewControllerAnimatedCompletion: vi.fn((controller: any) => {
106
+ bridge.presented.push(controller)
107
+ }),
108
+ }
109
+
110
+ vi.stubGlobal('android', {
111
+ content: {
112
+ Intent: class {
113
+ static ACTION_CREATE_DOCUMENT = 'android.intent.action.CREATE_DOCUMENT'
114
+ static CATEGORY_OPENABLE = 'android.intent.category.OPENABLE'
115
+ static EXTRA_TITLE = 'android.intent.extra.TITLE'
116
+ static FLAG_GRANT_READ_URI_PERMISSION = 1
117
+ static FLAG_GRANT_WRITE_URI_PERMISSION = 2
118
+ action: any
119
+ extras = new Map()
120
+ constructor(action: any) {
121
+ this.action = action
122
+ }
123
+ addCategory() {}
124
+ setType() {}
125
+ putExtra(key: any, value: any) {
126
+ this.extras.set(key, value)
127
+ }
128
+ addFlags() {}
129
+ },
130
+ },
131
+ app: { Activity: { RESULT_OK: -1 } },
132
+ net: { Uri: { parse: (s: string) => ({ toString: () => s }) } },
133
+ })
134
+
135
+ vi.stubGlobal('java', {
136
+ io: {
137
+ FileOutputStream: class {},
138
+ FileInputStream: class {},
139
+ ByteArrayOutputStream: class {
140
+ data: number[] = []
141
+ write(chunk: any, _off: number, count: number) {
142
+ for (let i = 0; i < count; i++) {
143
+ this.data.push(chunk[i])
144
+ }
145
+ }
146
+ size() {
147
+ return this.data.length
148
+ }
149
+ toByteArray() {
150
+ return this.data
151
+ }
152
+ },
153
+ BufferedReader: class {},
154
+ InputStreamReader: class {},
155
+ },
156
+ nio: {
157
+ channels: {
158
+ Channels: {
159
+ newChannel: (out: any) => ({
160
+ write: (buf: ArrayBuffer) => {
161
+ bridge.channelWrites.push({ out, buf })
162
+ return buf.byteLength
163
+ },
164
+ }),
165
+ },
166
+ },
167
+ },
168
+ })
169
+
170
+ vi.stubGlobal('NSData', {
171
+ dataWithBytesLength: (view: Uint8Array, length: number) => ({
172
+ view,
173
+ length,
174
+ writeToFileAtomically: vi.fn(() => bridge.writeAtomic),
175
+ }),
176
+ })
177
+
178
+ vi.stubGlobal('NSURL', { fileURLWithPath: (p: string) => ({ path: p }) })
179
+ vi.stubGlobal('UIApplication', {
180
+ sharedApplication: {
181
+ get keyWindow() {
182
+ return bridge.keyWindow ? { rootViewController: {} } : null
183
+ },
184
+ windows: { count: 0 },
185
+ },
186
+ })
187
+
188
+ vi.stubGlobal('UIDocumentPickerViewController', {
189
+ alloc: () => ({
190
+ initForExportingURLsAsCopy: (urls: any, asCopy: boolean) => {
191
+ lastPicker = { urls, asCopy, delegate: null, shouldShowFileExtensions: false }
192
+ return lastPicker
193
+ },
194
+ }),
195
+ })
196
+
197
+ vi.stubGlobal('UIDocumentPickerDelegate', {})
198
+ vi.stubGlobal(
199
+ 'NSObject',
200
+ class {
201
+ static new(this: any) {
202
+ return new this()
203
+ }
204
+ },
205
+ )
206
+ })
207
+
208
+ afterEach(() => {
209
+ bridge.application.android = undefined
210
+ vi.unstubAllGlobals()
211
+ vi.restoreAllMocks()
212
+ })
213
+
214
+ describe('files.writeBytes (ios bridge)', () => {
215
+ it('rejects when the atomic write reports failure instead of returning a dead ref', async () => {
216
+ bridge.writeAtomic = false
217
+ await expect(files.writeBytes('a.bin', new Uint8Array([1]))).rejects.toThrow(
218
+ /Failed to write file/,
219
+ )
220
+ })
221
+
222
+ it('returns a sandbox ref after a confirmed write', async () => {
223
+ const ref = await files.writeBytes('a.bin', new Uint8Array([1, 2]))
224
+ expect(ref).toEqual({ name: 'a.bin', uri: '/docs/a.bin' })
225
+ })
226
+ })
227
+
228
+ describe('files.export (android bridge)', () => {
229
+ beforeEach(stubAndroid)
230
+
231
+ it('flushes bytes to the picked URI and resolves saved', async () => {
232
+ const pending = files.export('out.bin', new Uint8Array([7, 8]))
233
+ expect(bridge.activity.startActivityForResult).toHaveBeenCalledTimes(1)
234
+ emitResult(okResult())
235
+ expect(await pending).toBe('saved')
236
+ expect(bridge.resolver.openOutputStream).toHaveBeenCalledTimes(1)
237
+ expect(bridge.channelWrites).toHaveLength(1)
238
+ expect(bridge.listeners.get('activityResult')?.size ?? 0).toBe(0)
239
+ })
240
+
241
+ it('resolves cancelled on a non-OK result and drops the listener', async () => {
242
+ const pending = files.export('out.bin', new Uint8Array([1]))
243
+ emitResult({ requestCode: 1241, resultCode: 0, intent: null })
244
+ expect(await pending).toBe('cancelled')
245
+ expect(bridge.resolver.openOutputStream).not.toHaveBeenCalled()
246
+ expect(bridge.listeners.get('activityResult')?.size ?? 0).toBe(0)
247
+ })
248
+
249
+ it('rejects a concurrent export with ExportBusyError and recovers after settle', async () => {
250
+ const first = files.export('a.bin', new Uint8Array([1]))
251
+ await expect(files.export('b.bin', new Uint8Array([2]))).rejects.toThrowError(
252
+ expect.objectContaining({ name: 'ExportBusyError' }),
253
+ )
254
+
255
+ expect(bridge.activity.startActivityForResult).toHaveBeenCalledTimes(1)
256
+
257
+ emitResult(okResult())
258
+ expect(await first).toBe('saved')
259
+
260
+ const third = files.export('c.bin', new Uint8Array([3]))
261
+ emitResult(okResult())
262
+ expect(await third).toBe('saved')
263
+ })
264
+
265
+ it('rejects when the destination stream cannot open and cleans up', async () => {
266
+ bridge.resolver.openOutputStream.mockReturnValue(null)
267
+ const pending = files.export('out.bin', new Uint8Array([1]))
268
+ emitResult(okResult())
269
+ await expect(pending).rejects.toThrow(/Unable to open destination/)
270
+ expect(bridge.listeners.get('activityResult')?.size ?? 0).toBe(0)
271
+
272
+ // Guard released — a follow-up export is not stuck busy.
273
+ bridge.resolver.openOutputStream.mockReturnValue({ close: vi.fn() })
274
+ const next = files.export('next.bin', new Uint8Array([1]))
275
+ emitResult(okResult())
276
+ expect(await next).toBe('saved')
277
+ })
278
+
279
+ it('rejects when the activity cannot be started and removes the listener', async () => {
280
+ bridge.activity.startActivityForResult.mockImplementation(() => {
281
+ throw new Error('no activity to handle intent')
282
+ })
283
+
284
+ await expect(files.export('out.bin', new Uint8Array([1]))).rejects.toThrow(/no activity/)
285
+ expect(bridge.listeners.get('activityResult')?.size ?? 0).toBe(0)
286
+ })
287
+ })
288
+
289
+ describe('files.export (ios bridge)', () => {
290
+ it('stages a temp copy, presents export-as-copy, and resolves saved with cleanup', async () => {
291
+ const pending = files.export('out.bin', new Uint8Array([9]))
292
+ expect(bridge.presented).toHaveLength(1)
293
+ expect(lastPicker.asCopy).toBe(true)
294
+ expect(lastPicker.delegate).toBeTruthy()
295
+
296
+ lastPicker.delegate.documentPickerDidPickDocumentAtURL(lastPicker, { path: '/dest' })
297
+ expect(await pending).toBe('saved')
298
+ expect(bridge.removedPaths).toEqual(['/tmp/out.bin'])
299
+ expect(lastPicker.delegate).toBeNull()
300
+ })
301
+
302
+ it('resolves cancelled on dismissal and removes the temp copy', async () => {
303
+ const pending = files.export('out.bin', new Uint8Array([9]))
304
+ lastPicker.delegate.documentPickerWasCancelled(lastPicker)
305
+ expect(await pending).toBe('cancelled')
306
+ expect(bridge.removedPaths).toEqual(['/tmp/out.bin'])
307
+ })
308
+
309
+ it('rejects when the temp write fails instead of hanging', async () => {
310
+ bridge.writeAtomic = false
311
+ await expect(files.export('out.bin', new Uint8Array([1]))).rejects.toThrow(
312
+ /Failed to stage export file/,
313
+ )
314
+
315
+ expect(bridge.presented).toHaveLength(0)
316
+ })
317
+
318
+ it('rejects when no view controller can present instead of hanging', async () => {
319
+ bridge.keyWindow = false
320
+ await expect(files.export('out.bin', new Uint8Array([1]))).rejects.toThrow(
321
+ /No visible view controller/,
322
+ )
323
+
324
+ expect(bridge.removedPaths).toEqual(['/tmp/out.bin'])
325
+ })
326
+
327
+ it('rejects a concurrent export with ExportBusyError', async () => {
328
+ const first = files.export('a.bin', new Uint8Array([1]))
329
+ await expect(files.export('b.bin', new Uint8Array([2]))).rejects.toThrowError(
330
+ expect.objectContaining({ name: 'ExportBusyError' }),
331
+ )
332
+
333
+ lastPicker.delegate.documentPickerDidPickDocumentsAtURLs(lastPicker, { count: 1 })
334
+ expect(await first).toBe('saved')
335
+ })
336
+ })
337
+
338
+ describe('files.export (macos leaf)', () => {
339
+ it('reports unavailable instead of throwing', async () => {
340
+ expect(await macosFiles.export('a.bin', new Uint8Array([1]))).toBe('unavailable')
341
+ })
342
+ })
@@ -1,5 +1,11 @@
1
1
  // Files — AppKit host leaf.
2
- import type { FileRef } from './types'
2
+ import type {
3
+ FileExportOptions,
4
+ FileExportResult,
5
+ FileReadBytesOptions,
6
+ FileRef,
7
+ Files,
8
+ } from './types'
3
9
 
4
10
  function openPanel(accept = '*/*', multiple = false): FileRef[] | null {
5
11
  const appKit = globalThis as any
@@ -42,7 +48,7 @@ function openPanel(accept = '*/*', multiple = false): FileRef[] | null {
42
48
  }))
43
49
  }
44
50
 
45
- export const files = {
51
+ export const files: Files = {
46
52
  async pick(accept = '*/*'): Promise<FileRef | null> {
47
53
  return openPanel(accept)?.[0] ?? null
48
54
  },
@@ -52,8 +58,23 @@ export const files = {
52
58
  async readText(_file: FileRef): Promise<string> {
53
59
  throw new Error('unsupported: AppKit file access is not wired')
54
60
  },
61
+ async readBytes(_file: FileRef, _opts?: FileReadBytesOptions): Promise<Uint8Array> {
62
+ throw new Error('unsupported: AppKit file access is not wired')
63
+ },
55
64
  async writeText(_name: string, _text: string): Promise<FileRef> {
56
65
  throw new Error('unsupported: the AppKit host does not provide a save panel')
57
66
  },
67
+ async writeBytes(_name: string, _bytes: Uint8Array): Promise<FileRef> {
68
+ throw new Error('unsupported: the AppKit host does not provide a save panel')
69
+ },
70
+ // FileExportResult names this leaf as the 'unavailable' case — the AppKit
71
+ // host offers no save panel, so report it instead of throwing.
72
+ async export(
73
+ _name: string,
74
+ _bytes: Uint8Array,
75
+ _opts?: FileExportOptions,
76
+ ): Promise<FileExportResult> {
77
+ return 'unavailable'
78
+ },
58
79
  release(_file: FileRef): void {},
59
80
  }