mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-24 21:40:48 +02:00
* fix(fs): report read errors of readTextFileLines In plugins/fs/src/commands.rs read_text_file_lines_next, a read error was mapped to "not done, empty line", so a persistent error such as EISDIR (File::open of a directory succeeds on Unix) made the iterator yield "" forever and the error was swallowed. The command now closes the resource and returns the error; the JS iterator resets its rid so a new iteration starts over. api-iife.js regenerated. E2E spec iterates a directory and expects a rejection. * fix(fs): close the file when a readTextFileLines loop exits early In plugins/fs/guest-js/index.ts readTextFileLines, the async iterator did not implement return(), so breaking out of a for await loop left the StdLinesResource (an open file) in the webview resource table. return() now closes the resource and resets the iterator. api-iife.js regenerated. E2E spec breaks out of a loop, checks the resource id is no longer valid and that iterating again starts over.
451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
import { expect } from '@wdio/globals'
|
|
import {
|
|
tauri,
|
|
tauriError,
|
|
describePlugin,
|
|
itDesktop,
|
|
scratchDir
|
|
} from '../helpers/index.js'
|
|
|
|
// Every path below is relative to `BaseDirectory.AppData`, which the example's
|
|
// fs scope allows recursively (`fs:scope-appdata-recursive`).
|
|
const dir = scratchDir('fs')
|
|
|
|
describePlugin('fs', () => {
|
|
before(async () => {
|
|
await tauri(async (api, dir) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
if (await api.fs.exists(dir, { baseDir })) {
|
|
await api.fs.remove(dir, { baseDir, recursive: true })
|
|
}
|
|
await api.fs.mkdir(dir, { baseDir, recursive: true })
|
|
}, dir)
|
|
})
|
|
|
|
after(async () => {
|
|
await tauri(async (api, dir) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
if (await api.fs.exists(dir, { baseDir })) {
|
|
await api.fs.remove(dir, { baseDir, recursive: true })
|
|
}
|
|
}, dir)
|
|
})
|
|
|
|
it('mkdir and exists report the scratch directory', async () => {
|
|
const result = await tauri(async (api, dir) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
return {
|
|
dir: await api.fs.exists(dir, { baseDir }),
|
|
missing: await api.fs.exists(`${dir}/does-not-exist`, { baseDir })
|
|
}
|
|
}, dir)
|
|
expect(result).toEqual({ dir: true, missing: false })
|
|
})
|
|
|
|
it('writeTextFile and readTextFile round-trip utf-8 text', async () => {
|
|
const text = 'Hello from the e2e suite — olá, 世界! 🎉\nsecond line\n'
|
|
const read = await tauri(
|
|
async (api, path, text) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.writeTextFile(path, text, { baseDir })
|
|
return api.fs.readTextFile(path, { baseDir })
|
|
},
|
|
`${dir}/text.txt`,
|
|
text
|
|
)
|
|
expect(read).toBe(text)
|
|
})
|
|
|
|
it('writeTextFile appends when asked to', async () => {
|
|
const read = await tauri(async (api, path) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.writeTextFile(path, 'first', { baseDir })
|
|
await api.fs.writeTextFile(path, ' second', { baseDir, append: true })
|
|
return api.fs.readTextFile(path, { baseDir })
|
|
}, `${dir}/append.txt`)
|
|
expect(read).toBe('first second')
|
|
})
|
|
|
|
it('writeFile and writeTextFile work without options', async () => {
|
|
const result = await tauri(async (api, dir) => {
|
|
const path = await api.path.join(
|
|
await api.path.appDataDir(),
|
|
dir,
|
|
'no-options.txt'
|
|
)
|
|
await api.fs.writeTextFile(path, 'text')
|
|
const text = await api.fs.readTextFile(path)
|
|
await api.fs.writeFile(path, new Uint8Array([98, 121, 116, 101, 115]))
|
|
return { text, bytes: await api.fs.readTextFile(path) }
|
|
}, dir)
|
|
expect(result).toEqual({ text: 'text', bytes: 'bytes' })
|
|
})
|
|
|
|
it('writeFile and readFile round-trip binary data', async () => {
|
|
const bytes = [0, 1, 2, 3, 250, 251, 252, 253, 254, 255]
|
|
const read = await tauri(
|
|
async (api, path, bytes) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.writeFile(path, new Uint8Array(bytes), { baseDir })
|
|
return Array.from(await api.fs.readFile(path, { baseDir }))
|
|
},
|
|
`${dir}/binary.bin`,
|
|
bytes
|
|
)
|
|
expect(read).toEqual(bytes)
|
|
})
|
|
|
|
it('stat, lstat and size describe files and directories', async () => {
|
|
const result = await tauri(async (api, dir) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
const file = `${dir}/stat.txt`
|
|
await api.fs.writeTextFile(file, '0123456789', { baseDir })
|
|
const fileStat = await api.fs.stat(file, { baseDir })
|
|
const fileLstat = await api.fs.lstat(file, { baseDir })
|
|
const dirStat = await api.fs.stat(dir, { baseDir })
|
|
return {
|
|
file: {
|
|
isFile: fileStat.isFile,
|
|
isDirectory: fileStat.isDirectory,
|
|
isSymlink: fileStat.isSymlink,
|
|
size: fileStat.size,
|
|
hasMtime: fileStat.mtime instanceof Date
|
|
},
|
|
lstatSize: fileLstat.size,
|
|
size: await api.fs.size(file, { baseDir }),
|
|
absoluteSize: await api.fs.size(
|
|
await api.path.join(await api.path.appDataDir(), file)
|
|
),
|
|
dirSize: await api.fs.size(dir, { baseDir }),
|
|
dir: { isFile: dirStat.isFile, isDirectory: dirStat.isDirectory }
|
|
}
|
|
}, dir)
|
|
expect(result.file).toEqual({
|
|
isFile: true,
|
|
isDirectory: false,
|
|
isSymlink: false,
|
|
size: 10,
|
|
hasMtime: true
|
|
})
|
|
expect(result.lstatSize).toBe(10)
|
|
expect(result.size).toBe(10)
|
|
expect(result.absoluteSize).toBe(10)
|
|
expect(result.dirSize).toBeGreaterThanOrEqual(10)
|
|
expect(result.dir).toEqual({ isFile: false, isDirectory: true })
|
|
})
|
|
|
|
it('copyFile, rename and readDir', async () => {
|
|
const result = await tauri(async (api, dir) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
const sub = `${dir}/tree`
|
|
await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true })
|
|
await api.fs.writeTextFile(`${sub}/a.txt`, 'a', { baseDir })
|
|
await api.fs.copyFile(`${sub}/a.txt`, `${sub}/b.txt`, {
|
|
fromPathBaseDir: baseDir,
|
|
toPathBaseDir: baseDir
|
|
})
|
|
await api.fs.rename(`${sub}/b.txt`, `${sub}/c.txt`, {
|
|
oldPathBaseDir: baseDir,
|
|
newPathBaseDir: baseDir
|
|
})
|
|
const entries = await api.fs.readDir(sub, { baseDir })
|
|
return {
|
|
entries: entries
|
|
.map((e) => ({
|
|
name: e.name,
|
|
isFile: e.isFile,
|
|
isDirectory: e.isDirectory
|
|
}))
|
|
.sort((x, y) => x.name.localeCompare(y.name)),
|
|
copied: await api.fs.readTextFile(`${sub}/c.txt`, { baseDir }),
|
|
renamedAway: await api.fs.exists(`${sub}/b.txt`, { baseDir })
|
|
}
|
|
}, dir)
|
|
expect(result.entries).toEqual([
|
|
{ name: 'a.txt', isFile: true, isDirectory: false },
|
|
{ name: 'c.txt', isFile: true, isDirectory: false },
|
|
{ name: 'nested', isFile: false, isDirectory: true }
|
|
])
|
|
expect(result.copied).toBe('a')
|
|
expect(result.renamedAway).toBe(false)
|
|
})
|
|
|
|
it('remove deletes files and (recursively) directories', async () => {
|
|
const result = await tauri(async (api, dir) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
const sub = `${dir}/to-remove`
|
|
await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true })
|
|
await api.fs.writeTextFile(`${sub}/nested/file.txt`, 'x', { baseDir })
|
|
await api.fs.remove(`${sub}/nested/file.txt`, { baseDir })
|
|
const fileGone = !(await api.fs.exists(`${sub}/nested/file.txt`, {
|
|
baseDir
|
|
}))
|
|
await api.fs.writeTextFile(`${sub}/nested/other.txt`, 'x', { baseDir })
|
|
let nonRecursiveError: string | null = null
|
|
try {
|
|
await api.fs.remove(sub, { baseDir })
|
|
} catch (error) {
|
|
nonRecursiveError = String(error)
|
|
}
|
|
await api.fs.remove(sub, { baseDir, recursive: true })
|
|
return {
|
|
fileGone,
|
|
nonRecursiveError,
|
|
dirGone: !(await api.fs.exists(sub, { baseDir }))
|
|
}
|
|
}, dir)
|
|
expect(result.fileGone).toBe(true)
|
|
// a non-empty directory cannot be removed without `recursive`
|
|
expect(result.nonRecursiveError).not.toBeNull()
|
|
expect(result.dirGone).toBe(true)
|
|
})
|
|
|
|
it('truncate shortens a file', async () => {
|
|
const result = await tauri(async (api, path) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.writeTextFile(path, '0123456789', { baseDir })
|
|
await api.fs.truncate(path, 4, { baseDir })
|
|
const shortened = await api.fs.readTextFile(path, { baseDir })
|
|
await api.fs.truncate(path, undefined, { baseDir })
|
|
return {
|
|
shortened,
|
|
emptied: await api.fs.readTextFile(path, { baseDir })
|
|
}
|
|
}, `${dir}/truncate.txt`)
|
|
expect(result).toEqual({ shortened: '0123', emptied: '' })
|
|
})
|
|
|
|
it('readTextFileLines iterates a file line by line', async () => {
|
|
const lines = await tauri(async (api, path) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.writeTextFile(path, 'one\ntwo\r\nthree', { baseDir })
|
|
const result: string[] = []
|
|
for await (const line of await api.fs.readTextFileLines(path, {
|
|
baseDir
|
|
})) {
|
|
result.push(line)
|
|
}
|
|
return result
|
|
}, `${dir}/lines.txt`)
|
|
expect(lines).toEqual(['one', 'two', 'three'])
|
|
})
|
|
|
|
it('readTextFileLines closes the file when a loop exits early', async () => {
|
|
const result = await tauri(async (api, path) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.writeTextFile(path, 'one\ntwo\nthree', { baseDir })
|
|
const lines = await api.fs.readTextFileLines(path, { baseDir })
|
|
// the iterator keeps the id of the open file in `rid`
|
|
const state = lines as unknown as { rid: number | null }
|
|
let rid: number | null = null
|
|
let first: string | null = null
|
|
for await (const line of lines) {
|
|
first = line
|
|
rid = state.rid
|
|
break
|
|
}
|
|
let closeError: string | null = null
|
|
try {
|
|
await api.core.invoke('plugin:resources|close', { rid })
|
|
} catch (error) {
|
|
closeError = String(error)
|
|
}
|
|
// iterating again starts over
|
|
const all: string[] = []
|
|
for await (const line of lines) {
|
|
all.push(line)
|
|
}
|
|
return { first, ridAfter: state.rid, closeError, all }
|
|
}, `${dir}/lines-break.txt`)
|
|
expect(result.first).toBe('one')
|
|
expect(result.ridAfter).toBeNull()
|
|
// the resource was already closed by the iterator
|
|
expect(result.closeError).toMatch(/resource id \d+ is invalid/)
|
|
expect(result.all).toEqual(['one', 'two', 'three'])
|
|
})
|
|
|
|
it('readTextFileLines rejects when the file cannot be read', async () => {
|
|
// a directory: opening it fails on Windows, reading it fails elsewhere,
|
|
// which used to yield empty lines forever
|
|
const message = await tauriError(async (api, path) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.mkdir(path, { baseDir, recursive: true })
|
|
const lines = await api.fs.readTextFileLines(path, { baseDir })
|
|
for (let i = 0; i < 1000; i++) {
|
|
const { done } = await lines.next()
|
|
if (done) return
|
|
}
|
|
throw new Error('readTextFileLines kept yielding lines for a directory')
|
|
}, `${dir}/lines-dir`)
|
|
expect(message).not.toMatch(/kept yielding/)
|
|
expect(message).toMatch(/failed to (read line|open file)/)
|
|
})
|
|
|
|
it('FileHandle supports write, seek, read, stat and truncate', async () => {
|
|
const result = await tauri(async (api, path) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
const encoder = new TextEncoder()
|
|
const decoder = new TextDecoder()
|
|
|
|
const created = await api.fs.create(path, { baseDir })
|
|
const written = await created.write(encoder.encode('hello world'))
|
|
const statAfterWrite = await created.stat()
|
|
await created.close()
|
|
|
|
const file = await api.fs.open(path, { baseDir, read: true, write: true })
|
|
// seek past "hello " and read the rest
|
|
const position = await file.seek(6, api.fs.SeekMode.Start)
|
|
const buffer = new Uint8Array(32)
|
|
const read = await file.read(buffer)
|
|
const rest = decoder.decode(buffer.subarray(0, read ?? 0))
|
|
// at EOF, read reports null
|
|
const atEof = await file.read(new Uint8Array(8))
|
|
// relative and end-relative seeks
|
|
const fromEnd = await file.seek(-5, api.fs.SeekMode.End)
|
|
const relative = await file.seek(-1, api.fs.SeekMode.Current)
|
|
await file.truncate(5)
|
|
const statAfterTruncate = await file.stat()
|
|
await file.close()
|
|
|
|
return {
|
|
written,
|
|
sizeAfterWrite: statAfterWrite.size,
|
|
position,
|
|
read,
|
|
rest,
|
|
atEof,
|
|
fromEnd,
|
|
relative,
|
|
sizeAfterTruncate: statAfterTruncate.size,
|
|
contents: await api.fs.readTextFile(path, { baseDir })
|
|
}
|
|
}, `${dir}/handle.txt`)
|
|
expect(result).toEqual({
|
|
written: 11,
|
|
sizeAfterWrite: 11,
|
|
position: 6,
|
|
read: 5,
|
|
rest: 'world',
|
|
atEof: null,
|
|
fromEnd: 6,
|
|
relative: 5,
|
|
sizeAfterTruncate: 5,
|
|
contents: 'hello'
|
|
})
|
|
})
|
|
|
|
it('a closed FileHandle cannot be used again', async () => {
|
|
const message = await tauriError(async (api, path) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
const file = await api.fs.create(path, { baseDir })
|
|
await file.close()
|
|
await file.stat()
|
|
}, `${dir}/closed.txt`)
|
|
expect(message).toMatch(/resource id \d+ is invalid/)
|
|
})
|
|
|
|
it('rejects paths outside the configured scope', async () => {
|
|
// `$HOME` itself is not in the example's fs scope (only the app dirs,
|
|
// `$DOWNLOAD` and `$RESOURCE` are).
|
|
const message = await tauriError(async (api) =>
|
|
api.fs.readTextFile('e2e-forbidden.txt', {
|
|
baseDir: api.fs.BaseDirectory.Home
|
|
})
|
|
)
|
|
expect(message).toMatch(/forbidden path/)
|
|
})
|
|
|
|
it('rejects paths escaping the scope through `..`', async () => {
|
|
const message = await tauriError(async (api) =>
|
|
api.fs.writeTextFile('../../e2e-escape.txt', 'nope', {
|
|
baseDir: api.fs.BaseDirectory.AppData
|
|
})
|
|
)
|
|
expect(message).toMatch(/cannot traverse directory|forbidden path/)
|
|
})
|
|
|
|
// The watch specs are desktop-only: `fs:allow-watch` is granted in the
|
|
// example's desktop capability only.
|
|
itDesktop(
|
|
'watchImmediate reports changes in a watched directory',
|
|
async () => {
|
|
const result = await tauri(async (api, watched) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.mkdir(watched, { baseDir, recursive: true })
|
|
const events: { kind: string; paths: string[] }[] = []
|
|
const unwatch = await api.fs.watchImmediate(
|
|
watched,
|
|
(event) => {
|
|
events.push({
|
|
kind:
|
|
typeof event.type === 'string'
|
|
? event.type
|
|
: Object.keys(event.type)[0],
|
|
paths: event.paths
|
|
})
|
|
},
|
|
{ baseDir, recursive: true }
|
|
)
|
|
await api.fs.writeTextFile(`${watched}/touched.txt`, 'watched', {
|
|
baseDir
|
|
})
|
|
// give the notifier a moment to deliver
|
|
const deadline = Date.now() + 10_000
|
|
while (
|
|
!events.some((e) => e.paths.some((p) => p.endsWith('touched.txt')))
|
|
) {
|
|
if (Date.now() > deadline) {
|
|
throw new Error(
|
|
`no watch event for touched.txt, got ${JSON.stringify(events)}`
|
|
)
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
}
|
|
unwatch()
|
|
return events
|
|
}, `${dir}/watched`)
|
|
expect(
|
|
result.some((e) => e.paths.some((p) => p.endsWith('touched.txt')))
|
|
).toBe(true)
|
|
}
|
|
)
|
|
|
|
itDesktop('watch debounces and unwatch stops delivery', async () => {
|
|
const result = await tauri(async (api, watched) => {
|
|
const baseDir = api.fs.BaseDirectory.AppData
|
|
await api.fs.mkdir(watched, { baseDir, recursive: true })
|
|
let count = 0
|
|
const unwatch = await api.fs.watch(
|
|
watched,
|
|
() => {
|
|
count++
|
|
},
|
|
{ baseDir, delayMs: 200 }
|
|
)
|
|
await api.fs.writeTextFile(`${watched}/debounced.txt`, 'a', {
|
|
baseDir
|
|
})
|
|
const deadline = Date.now() + 10_000
|
|
while (count === 0) {
|
|
if (Date.now() > deadline) {
|
|
throw new Error('no debounced watch event received')
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
}
|
|
const afterFirst = count
|
|
unwatch()
|
|
await api.fs.writeTextFile(`${watched}/after-unwatch.txt`, 'b', {
|
|
baseDir
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
return { afterFirst, afterUnwatch: count }
|
|
}, `${dir}/debounced`)
|
|
expect(result.afterFirst).toBeGreaterThan(0)
|
|
expect(result.afterUnwatch).toBe(result.afterFirst)
|
|
})
|
|
})
|