expo-file-system
Version:
Provides access to the local file system on the device.
874 lines (773 loc) • 25.1 kB
text/typescript
/**
* Hand-maintained mock for the FileSystem native module.
*
* Backs the class-based API (`File`, `Directory`, `Paths` from
* `expo-file-system`) with a small in-memory filesystem so tests can exercise
* create/write/read/move/copy/delete end-to-end. This module is what
* `jest-expo`'s preset feeds to `requireNativeModule('FileSystem')`.
* Timestamps use a logical clock reset by `__resetMockFileSystem()`; do not use
* `Date.now()` here.
*
* DO NOT regenerate this file with `expo-modules-test-core` — the generator
* emits a bare stub and will overwrite the behavior here. Same pattern as
* `packages/expo-crypto/mocks/ExpoCryptoAES.ts`.
*/
import { FileMode } from '../src/File.types';
export type URL = string;
export type FileSystemPath = any;
export type DownloadOptions = any;
export type InfoOptions = any;
export type TypedArray = any;
export type CreateOptions = any;
export const documentDirectory = 'file:///mock/document/';
export const cacheDirectory = 'file:///mock/cache/';
export const bundleDirectory = 'file:///mock/bundle/';
export const appleSharedContainers: Record<string, string> = {};
export const totalDiskSpace = 1_000_000_000;
export const availableDiskSpace = 500_000_000;
type Entry = {
kind: 'file' | 'dir';
bytes?: Uint8Array;
type?: string | null;
exists: boolean;
createdAt?: number;
modifiedAt?: number;
};
const store = new Map<string, Entry>();
const SEED_DIRS = [documentDirectory, cacheDirectory, bundleDirectory];
function seed() {
const t = nextMockTimestamp();
for (const uri of SEED_DIRS) {
store.set(normalizeKey(uri), { kind: 'dir', exists: true, createdAt: t, modifiedAt: t });
}
}
/**
* Logical clock used for `createdAt` / `modifiedAt` on stored entries.
* Resetting the mock filesystem also resets it so each test sees a stable
* sequence of timestamps independent of wall-clock time.
*/
let mockClock = 0;
function nextMockTimestamp(): number {
return ++mockClock;
}
/**
* Test-only helper: reset the in-memory filesystem to a clean state with only
* the canonical `document`, `cache`, and `bundle` directories seeded.
* Call from a `beforeEach` to keep tests isolated.
*/
export function __resetMockFileSystem() {
store.clear();
listeners.clear();
cancelled.clear();
mockClock = 0;
seed();
}
seed();
function normalizeKey(uri: string): string {
return uri.endsWith('/') ? uri.slice(0, -1) : uri;
}
function basename(uri: string): string {
const key = normalizeKey(uri);
const i = key.lastIndexOf('/');
return i === -1 ? key : key.slice(i + 1);
}
function parentOf(uri: string): string {
const key = normalizeKey(uri);
const i = key.lastIndexOf('/');
return i === -1 ? '' : key.slice(0, i);
}
function joinUri(dir: string, name: string): string {
const base = normalizeKey(dir);
return `${base}/${name}`;
}
function utf8Encode(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
function utf8Decode(b: Uint8Array): string {
return new TextDecoder('utf-8').decode(b);
}
function base64Encode(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64');
}
function base64Decode(str: string): Uint8Array {
return new Uint8Array(Buffer.from(str, 'base64'));
}
function fakeMd5(uri: string, size: number): string {
let h = 0x811c9dc5;
const src = `${uri}:${size}`;
for (let i = 0; i < src.length; i++) {
h = Math.imul(h ^ src.charCodeAt(i), 0x01000193);
}
const hex = (h >>> 0).toString(16).padStart(8, '0');
return hex.repeat(4).slice(0, 32);
}
const listeners = new Map<string, Set<(event: any) => void>>();
const cancelled = new Set<string>();
function emit(event: string, data: any) {
const set = listeners.get(event);
if (!set) return;
for (const fn of set) {
try {
fn(data);
} catch {
/* ignore listener errors in the mock */
}
}
}
export function addListener(event: string, handler: (data: any) => void) {
let set = listeners.get(event);
if (!set) {
set = new Set();
listeners.set(event, set);
}
set.add(handler);
return {
remove: () => {
set!.delete(handler);
},
};
}
export function info(uri: string): { exists: boolean; isDirectory: boolean | null } {
const entry = store.get(normalizeKey(uri));
if (!entry || !entry.exists) return { exists: false, isDirectory: null };
return { exists: true, isDirectory: entry.kind === 'dir' };
}
function assertParent(uri: string, allowMissing: boolean) {
const parent = normalizeKey(parentOf(uri));
if (!parent) return;
const entry = store.get(parent);
if (entry?.exists) return;
if (!allowMissing) {
throw new Error('Parent directory does not exist');
}
const missing: string[] = [];
let cursor = parent;
// Stop walking once we've peeled back to the scheme (e.g. "file://") so we
// don't seed nonsense keys above the root.
while (cursor && cursor.length >= 8 && !store.get(cursor)?.exists) {
missing.unshift(cursor);
const next = normalizeKey(parentOf(cursor));
if (next === cursor) break;
cursor = next;
}
const now = nextMockTimestamp();
for (const dirKey of missing) {
store.set(dirKey, { kind: 'dir', exists: true, createdAt: now, modifiedAt: now });
}
}
export class FileSystemFile {
uri: string;
constructor(uri: string) {
this.uri = uri;
}
validatePath(): void {}
get exists(): boolean {
const entry = store.get(normalizeKey(this.uri));
return !!(entry && entry.kind === 'file' && entry.exists);
}
get size(): number {
const entry = store.get(normalizeKey(this.uri));
return entry && entry.kind === 'file' && entry.exists ? (entry.bytes?.length ?? 0) : 0;
}
get type(): string {
const entry = store.get(normalizeKey(this.uri));
return entry?.type ?? '';
}
get md5(): string | null {
return this.exists ? fakeMd5(this.uri, this.size) : null;
}
get modificationTime(): number | null {
const entry = store.get(normalizeKey(this.uri));
return entry?.exists ? (entry.modifiedAt ?? null) : null;
}
get lastModified(): number | null {
return this.modificationTime;
}
get creationTime(): number | null {
const entry = store.get(normalizeKey(this.uri));
return entry?.exists ? (entry.createdAt ?? null) : null;
}
get contentUri(): string {
return '';
}
create(options: { intermediates?: boolean; overwrite?: boolean } = {}): void {
const key = normalizeKey(this.uri);
const existing = store.get(key);
if (existing?.exists) {
if (!options.overwrite) {
throw new Error('File already exists');
}
}
assertParent(this.uri, !!options.intermediates);
const now = nextMockTimestamp();
store.set(key, {
kind: 'file',
bytes: new Uint8Array(0),
exists: true,
createdAt: now,
modifiedAt: now,
});
}
write(
content: string | Uint8Array,
options: { append?: boolean; encoding?: 'utf8' | 'base64' } = {}
): void {
assertParent(this.uri, false);
let bytes: Uint8Array;
if (typeof content === 'string') {
bytes = options.encoding === 'base64' ? base64Decode(content) : utf8Encode(content);
} else {
bytes = new Uint8Array(content);
}
const key = normalizeKey(this.uri);
const existing = store.get(key);
const now = nextMockTimestamp();
const createdAt = existing?.exists ? (existing.createdAt ?? now) : now;
const type = existing?.type ?? null;
if (options.append) {
const prior = existing?.bytes ?? new Uint8Array(0);
const merged = new Uint8Array(prior.length + bytes.length);
merged.set(prior, 0);
merged.set(bytes, prior.length);
store.set(key, {
kind: 'file',
bytes: merged,
type,
exists: true,
createdAt,
modifiedAt: now,
});
} else {
store.set(key, { kind: 'file', bytes, type, exists: true, createdAt, modifiedAt: now });
}
}
private readBytesOrThrow(): Uint8Array {
const entry = store.get(normalizeKey(this.uri));
if (!entry || entry.kind !== 'file' || !entry.exists) {
throw new Error('File does not exist');
}
return entry.bytes ?? new Uint8Array(0);
}
textSync(): string {
return utf8Decode(this.readBytesOrThrow());
}
base64Sync(): string {
return base64Encode(this.readBytesOrThrow());
}
bytesSync(): Uint8Array {
return new Uint8Array(this.readBytesOrThrow());
}
async text(): Promise<string> {
return this.textSync();
}
async base64(): Promise<string> {
return this.base64Sync();
}
async bytes(): Promise<Uint8Array> {
return this.bytesSync();
}
info(options: { md5?: boolean } = {}): any {
const entry = store.get(normalizeKey(this.uri));
if (!entry || entry.kind !== 'file' || !entry.exists) {
return { exists: false, uri: this.uri };
}
const size = entry.bytes?.length ?? 0;
return {
exists: true,
uri: this.uri,
size,
modificationTime: entry.modifiedAt ?? null,
creationTime: entry.createdAt ?? null,
...(options.md5 ? { md5: fakeMd5(this.uri, size) } : {}),
};
}
open(mode?: FileMode): FileSystemFileHandle {
const key = normalizeKey(this.uri);
const entry = store.get(key);
if (!entry || entry.kind !== 'file' || !entry.exists) {
throw new Error('File does not exist');
}
return new FileSystemFileHandle(key, mode ?? defaultModeForUri(this.uri));
}
delete(): void {
const key = normalizeKey(this.uri);
const entry = store.get(key);
if (!entry || !entry.exists) {
throw new Error('File does not exist');
}
store.delete(key);
}
private resolveDestination(destination: FileSystemFile | FileSystemDirectory): string {
if (destination instanceof FileSystemDirectory) {
return joinUri(destination.uri, basename(this.uri));
}
return normalizeKey(destination.uri);
}
copySync(
destination: FileSystemFile | FileSystemDirectory,
options: { overwrite?: boolean } = {}
): void {
const srcKey = normalizeKey(this.uri);
const entry = store.get(srcKey);
if (!entry || entry.kind !== 'file' || !entry.exists) {
throw new Error('File does not exist');
}
const destKey = this.resolveDestination(destination);
const destExisting = store.get(destKey);
if (destExisting?.exists && !options.overwrite) {
throw new Error('Destination already exists');
}
assertParent(destKey, false);
const now = nextMockTimestamp();
store.set(destKey, {
kind: 'file',
bytes: new Uint8Array(entry.bytes ?? new Uint8Array(0)),
type: entry.type ?? null,
exists: true,
createdAt: now,
modifiedAt: now,
});
}
async copy(
destination: FileSystemFile | FileSystemDirectory,
options: { overwrite?: boolean } = {}
): Promise<void> {
this.copySync(destination, options);
}
moveSync(
destination: FileSystemFile | FileSystemDirectory,
options: { overwrite?: boolean } = {}
): void {
const srcKey = normalizeKey(this.uri);
this.copySync(destination, options);
const destKey = this.resolveDestination(destination);
if (destKey !== srcKey) {
store.delete(srcKey);
}
this.uri = destKey;
}
async move(
destination: FileSystemFile | FileSystemDirectory,
options: { overwrite?: boolean } = {}
): Promise<void> {
this.moveSync(destination, options);
}
rename(newName: string): void {
const srcKey = normalizeKey(this.uri);
const entry = store.get(srcKey);
if (!entry || !entry.exists) {
throw new Error('File does not exist');
}
const destKey = joinUri(parentOf(this.uri), newName);
store.set(destKey, entry);
store.delete(srcKey);
this.uri = destKey;
}
watch(_callback: any, _options?: any): { remove: () => void } {
return { remove: () => {} };
}
}
export class FileSystemFileHandle {
private readonly key: string;
private readonly readOnly: boolean;
private readonly writeOnly: boolean;
private cursor: number;
private closed = false;
constructor(key: string, mode: FileMode) {
this.key = key;
const entry = store.get(key);
switch (mode) {
case FileMode.ReadWrite:
this.readOnly = false;
this.writeOnly = false;
this.cursor = 0;
break;
case FileMode.ReadOnly:
this.readOnly = true;
this.writeOnly = false;
this.cursor = 0;
break;
case FileMode.WriteOnly:
this.readOnly = false;
this.writeOnly = true;
this.cursor = 0;
break;
case FileMode.Append:
this.readOnly = false;
this.writeOnly = true;
this.cursor = entry?.bytes?.length ?? 0;
break;
case FileMode.Truncate:
this.readOnly = false;
this.writeOnly = true;
store.set(key, {
kind: 'file',
bytes: new Uint8Array(0),
type: entry?.type ?? null,
exists: true,
createdAt: entry?.createdAt ?? nextMockTimestamp(),
modifiedAt: nextMockTimestamp(),
});
this.cursor = 0;
break;
default:
assertNever(mode);
}
}
private ensureOpen() {
if (this.closed) throw new Error('File handle is closed');
}
get offset(): number | null {
return this.closed ? null : this.cursor;
}
set offset(value: number | null) {
if (this.closed || value == null) {
return;
}
this.cursor = value;
}
get size(): number | null {
if (this.closed) {
return null;
}
const entry = store.get(this.key);
return entry?.bytes?.length ?? 0;
}
readBytes(count: number): Uint8Array {
this.ensureOpen();
if (this.writeOnly) throw new Error('File handle is write-only');
const entry = store.get(this.key);
const bytes = entry?.bytes ?? new Uint8Array(0);
const slice = bytes.slice(this.cursor, this.cursor + count);
this.cursor += slice.length;
return slice;
}
writeBytes(buffer: Uint8Array): void {
this.ensureOpen();
if (this.readOnly) throw new Error('File handle is read-only');
const entry = store.get(this.key) ?? {
kind: 'file' as const,
bytes: new Uint8Array(0),
exists: true,
};
const prior = entry.bytes ?? new Uint8Array(0);
const writeOffset = Math.min(this.cursor, prior.length);
const newLength = Math.max(prior.length, writeOffset + buffer.length);
const merged = new Uint8Array(newLength);
merged.set(prior, 0);
merged.set(buffer, writeOffset);
store.set(this.key, {
...entry,
kind: 'file',
bytes: merged,
exists: true,
modifiedAt: nextMockTimestamp(),
});
this.cursor = writeOffset + buffer.length;
}
close(): void {
this.closed = true;
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled FileMode in jest-expo mock: ${String(value)}`);
}
function defaultModeForUri(uri: string): FileMode {
return uri.startsWith('content://') ? FileMode.ReadOnly : FileMode.ReadWrite;
}
export class FileSystemDirectory {
uri: string;
constructor(uri: string) {
this.uri = uri;
}
validatePath(): void {}
get exists(): boolean {
const entry = store.get(normalizeKey(this.uri));
return !!(entry && entry.kind === 'dir' && entry.exists);
}
get size(): number | null {
const entry = store.get(normalizeKey(this.uri));
if (!entry || entry.kind !== 'dir' || !entry.exists) {
return null;
}
const prefix = `${normalizeKey(this.uri)}/`;
let total = 0;
for (const [key, e] of store) {
if (!e.exists || e.kind !== 'file' || !key.startsWith(prefix)) continue;
total += e.bytes?.length ?? 0;
}
return total;
}
info(): any {
const entry = store.get(normalizeKey(this.uri));
if (!entry || entry.kind !== 'dir' || !entry.exists) {
return { exists: false, uri: this.uri };
}
return {
exists: true,
uri: this.uri,
files: directChildren(this.uri).map((child) => basename(child.uri)),
size: this.size,
modificationTime: entry.modifiedAt ?? null,
creationTime: entry.createdAt ?? null,
};
}
create(
options: { intermediates?: boolean; idempotent?: boolean; overwrite?: boolean } = {}
): void {
const key = normalizeKey(this.uri);
const existing = store.get(key);
if (existing?.exists) {
if (options.idempotent) return;
if (!options.overwrite) {
throw new Error('Directory already exists');
}
deleteSubtree(key);
}
assertParent(this.uri, !!options.intermediates);
const now = nextMockTimestamp();
store.set(key, { kind: 'dir', exists: true, createdAt: now, modifiedAt: now });
}
delete(): void {
const key = normalizeKey(this.uri);
const entry = store.get(key);
if (!entry || !entry.exists) {
throw new Error('Directory does not exist');
}
deleteSubtree(key);
}
listAsRecords(): { isDirectory: boolean; uri: string }[] {
const entry = store.get(normalizeKey(this.uri));
if (!entry || entry.kind !== 'dir' || !entry.exists) {
throw new Error('Directory does not exist');
}
return directChildren(this.uri).map(({ uri, kind }) => ({
isDirectory: kind === 'dir',
uri,
}));
}
createFile(name: string, mimeType: string | null): FileSystemFile {
const parentKey = normalizeKey(this.uri);
const parent = store.get(parentKey);
if (!parent || parent.kind !== 'dir' || !parent.exists) {
throw new Error('Parent directory does not exist');
}
const childKey = joinUri(this.uri, name);
if (store.get(childKey)?.exists) {
throw new Error('File already exists');
}
const now = nextMockTimestamp();
store.set(childKey, {
kind: 'file',
bytes: new Uint8Array(0),
type: mimeType ?? null,
exists: true,
createdAt: now,
modifiedAt: now,
});
return new FileSystemFile(childKey);
}
createDirectory(name: string): FileSystemDirectory {
const parentKey = normalizeKey(this.uri);
const parent = store.get(parentKey);
if (!parent || parent.kind !== 'dir' || !parent.exists) {
throw new Error('Parent directory does not exist');
}
const childKey = joinUri(this.uri, name);
if (store.get(childKey)?.exists) {
throw new Error('Directory already exists');
}
const now = nextMockTimestamp();
store.set(childKey, { kind: 'dir', exists: true, createdAt: now, modifiedAt: now });
return new FileSystemDirectory(childKey);
}
private resolveDestination(destination: FileSystemFile | FileSystemDirectory): string {
if (destination instanceof FileSystemDirectory) {
return joinUri(destination.uri, basename(this.uri));
}
return normalizeKey(destination.uri);
}
copySync(
destination: FileSystemFile | FileSystemDirectory,
options: { overwrite?: boolean } = {}
): void {
const srcKey = normalizeKey(this.uri);
const entry = store.get(srcKey);
if (!entry || entry.kind !== 'dir' || !entry.exists) {
throw new Error('Directory does not exist');
}
const destKey = this.resolveDestination(destination);
const destExisting = store.get(destKey);
if (destExisting?.exists && !options.overwrite) {
throw new Error('Destination already exists');
}
assertParent(destKey, false);
copySubtree(srcKey, destKey);
}
async copy(
destination: FileSystemFile | FileSystemDirectory,
options: { overwrite?: boolean } = {}
): Promise<void> {
this.copySync(destination, options);
}
moveSync(
destination: FileSystemFile | FileSystemDirectory,
options: { overwrite?: boolean } = {}
): void {
const srcKey = normalizeKey(this.uri);
this.copySync(destination, options);
const destKey = this.resolveDestination(destination);
if (destKey !== srcKey) {
deleteSubtree(srcKey);
}
this.uri = destKey;
}
async move(
destination: FileSystemFile | FileSystemDirectory,
options: { overwrite?: boolean } = {}
): Promise<void> {
this.moveSync(destination, options);
}
rename(newName: string): void {
const srcKey = normalizeKey(this.uri);
const entry = store.get(srcKey);
if (!entry || !entry.exists) {
throw new Error('Directory does not exist');
}
const destKey = joinUri(parentOf(this.uri), newName);
copySubtree(srcKey, destKey);
deleteSubtree(srcKey);
this.uri = destKey;
}
watch(_callback: any, _options?: any): { remove: () => void } {
return { remove: () => {} };
}
}
export class FileSystemWatcher {
constructor(_path: string, _options?: { debounce?: number; events?: string[] }) {}
addListener(_event: string, _callback: (data: any) => void): { remove: () => void } {
return { remove: () => {} };
}
start(): void {}
stop(): void {}
}
// Native task handle mocks.
// In the test environment the polyfill `SharedObject` is installed on
// `globalThis.expo` by jest-expo's setup before mock modules are loaded.
// Public `UploadTask` / `DownloadTask` instances compose these native handles,
// so the handles provide SharedObject APIs while the public tasks expose only
// their explicit facade methods.
const { SharedObject } = globalThis.expo;
export class FileSystemUploadTask extends SharedObject {
start(_url: string, _file: any, _options: any): Promise<any> {
return Promise.resolve({ body: '', status: 200, headers: {} });
}
release(): void {
super.release();
}
cancel(): void {}
}
export class FileSystemDownloadTask extends SharedObject {
start(_url: string, _to: any, _options?: any): Promise<string | null> {
return Promise.resolve('file:///mock/downloaded-file');
}
pause(): { resumeData: string } {
return { resumeData: 'mock-resume-data' };
}
resume(
_url: string,
_to: any,
_resumeData: string,
_options?: any
): Promise<string | null> {
return Promise.resolve('file:///mock/downloaded-file');
}
release(): void {
super.release();
}
cancel(): void {}
}
function directChildren(dirUri: string): { uri: string; kind: 'file' | 'dir' }[] {
const prefix = `${normalizeKey(dirUri)}/`;
const result: { uri: string; kind: 'file' | 'dir' }[] = [];
for (const [key, entry] of store) {
if (!entry.exists || !key.startsWith(prefix)) continue;
const tail = key.slice(prefix.length);
if (tail.length === 0 || tail.includes('/')) continue;
result.push({ uri: key, kind: entry.kind });
}
return result;
}
function deleteSubtree(rootKey: string) {
const prefix = `${rootKey}/`;
const keys: string[] = [rootKey];
for (const key of store.keys()) {
if (key.startsWith(prefix)) keys.push(key);
}
for (const key of keys) store.delete(key);
}
function copySubtree(srcKey: string, destKey: string) {
const srcEntry = store.get(srcKey);
if (!srcEntry) return;
const now = nextMockTimestamp();
store.set(destKey, cloneEntry(srcEntry, now));
const prefix = `${srcKey}/`;
for (const [key, entry] of store) {
if (!key.startsWith(prefix)) continue;
const rewritten = destKey + key.slice(srcKey.length);
store.set(rewritten, cloneEntry(entry, now));
}
}
function cloneEntry(entry: Entry, timestamp: number): Entry {
return {
kind: entry.kind,
exists: entry.exists,
type: entry.type ?? null,
bytes: entry.bytes ? new Uint8Array(entry.bytes) : undefined,
createdAt: timestamp,
modifiedAt: timestamp,
};
}
export async function downloadFileAsync(
url: URL,
to: { uri: string } | undefined,
options: any,
uuid: string | undefined
): Promise<string> {
if (options?.signal?.aborted) {
const err = new Error(options.signal.reason ?? 'The operation was aborted.');
err.name = 'AbortError';
throw err;
}
const bytes = utf8Encode(`mock:${url}`);
const toUri = to?.uri ?? joinUri(cacheDirectory, basename(url));
const toEntry = store.get(normalizeKey(toUri));
const destKey = toEntry?.kind === 'dir' ? joinUri(toUri, basename(url)) : normalizeKey(toUri);
if (uuid) {
emit('downloadProgress', {
uuid,
data: { bytesWritten: bytes.length, totalBytes: bytes.length },
});
}
if (uuid && cancelled.has(uuid)) {
cancelled.delete(uuid);
const err = new Error('Download was cancelled.');
err.name = 'AbortError';
throw err;
}
const now = nextMockTimestamp();
store.set(destKey, { kind: 'file', bytes, exists: true, createdAt: now, modifiedAt: now });
return destKey;
}
export function cancelDownloadAsync(uuid: string): void {
cancelled.add(uuid);
}
export async function pickDirectoryAsync(_initialUri?: string): Promise<{ uri: string }> {
return { uri: 'file:///mock/picked/directory' };
}
export async function pickFileAsync(options?: any): Promise<any> {
if (options?.multipleFiles) {
return [{ uri: 'file:///mock/picked/file1.txt' }, { uri: 'file:///mock/picked/file2.txt' }];
}
return { uri: 'file:///mock/picked/file.txt' };
}