mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
refactor(tests): migrate safeWriteJson tests from Jest to Vitest and improve error handling
This commit is contained in:
parent
0714c48a9e
commit
84d4eb6d53
1 changed files with 279 additions and 421 deletions
|
|
@ -1,49 +1,49 @@
|
|||
const actualFsPromises = jest.requireActual("fs/promises")
|
||||
import { vi, describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"
|
||||
import * as actualFsPromises from "fs/promises"
|
||||
import * as fsSyncActual from "fs"
|
||||
import { Writable } from "stream"
|
||||
import { safeWriteJson } from "../safeWriteJson"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
|
||||
const originalFsPromisesRename = actualFsPromises.rename
|
||||
const originalFsPromisesUnlink = actualFsPromises.unlink
|
||||
const originalFsPromisesWriteFile = actualFsPromises.writeFile
|
||||
const _originalFsPromisesAccess = actualFsPromises.access
|
||||
const originalFsPromisesMkdir = actualFsPromises.mkdir
|
||||
|
||||
jest.mock("fs/promises", () => {
|
||||
const actual = jest.requireActual("fs/promises")
|
||||
vi.mock("fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("fs/promises")>("fs/promises")
|
||||
// Start with all actual implementations.
|
||||
const mockedFs = { ...actual }
|
||||
|
||||
// Selectively wrap functions with jest.fn() if they are spied on
|
||||
// Selectively wrap functions with vi.fn() if they are spied on
|
||||
// or have their implementations changed in tests.
|
||||
// This ensures that other fs.promises functions used by the SUT
|
||||
// (like proper-lockfile's internals) will use their actual implementations.
|
||||
mockedFs.writeFile = jest.fn(actual.writeFile)
|
||||
mockedFs.readFile = jest.fn(actual.readFile)
|
||||
mockedFs.rename = jest.fn(actual.rename)
|
||||
mockedFs.unlink = jest.fn(actual.unlink)
|
||||
mockedFs.access = jest.fn(actual.access)
|
||||
mockedFs.mkdtemp = jest.fn(actual.mkdtemp)
|
||||
mockedFs.rm = jest.fn(actual.rm)
|
||||
mockedFs.readdir = jest.fn(actual.readdir)
|
||||
mockedFs.mkdir = jest.fn(actual.mkdir)
|
||||
mockedFs.writeFile = vi.fn(actual.writeFile) as any
|
||||
mockedFs.readFile = vi.fn(actual.readFile) as any
|
||||
mockedFs.rename = vi.fn(actual.rename) as any
|
||||
mockedFs.unlink = vi.fn(actual.unlink) as any
|
||||
mockedFs.access = vi.fn(actual.access) as any
|
||||
mockedFs.mkdtemp = vi.fn(actual.mkdtemp) as any
|
||||
mockedFs.rm = vi.fn(actual.rm) as any
|
||||
mockedFs.readdir = vi.fn(actual.readdir) as any
|
||||
mockedFs.mkdir = vi.fn(actual.mkdir) as any
|
||||
// fs.stat and fs.lstat will be available via { ...actual }
|
||||
|
||||
return mockedFs
|
||||
})
|
||||
|
||||
// Mock the 'fs' module for fsSync.createWriteStream
|
||||
jest.mock("fs", () => {
|
||||
const actualFs = jest.requireActual("fs")
|
||||
vi.mock("fs", async () => {
|
||||
const actualFs = await vi.importActual<typeof import("fs")>("fs")
|
||||
return {
|
||||
...actualFs, // Spread actual implementations
|
||||
createWriteStream: jest.fn((...args: any[]) => actualFs.createWriteStream(...args)), // Default to actual, but mockable
|
||||
createWriteStream: vi.fn(actualFs.createWriteStream) as any, // Default to actual, but mockable
|
||||
}
|
||||
})
|
||||
|
||||
import * as fs from "fs/promises" // This will now be the mocked version
|
||||
import * as fsSyncActual from "fs" // This will now import the mocked 'fs'
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
// import * as lockfile from 'proper-lockfile' // No longer directly used in tests
|
||||
import { safeWriteJson } from "../safeWriteJson"
|
||||
import { Writable } from "stream" // For typing mock stream
|
||||
|
||||
describe("safeWriteJson", () => {
|
||||
let originalConsoleError: typeof console.error
|
||||
|
|
@ -51,571 +51,429 @@ describe("safeWriteJson", () => {
|
|||
beforeAll(() => {
|
||||
// Store original console.error
|
||||
originalConsoleError = console.error
|
||||
|
||||
// Replace with filtered version that suppresses output from the module
|
||||
console.error = function (...args) {
|
||||
// Check if call originated from safeWriteJson.ts
|
||||
if (new Error().stack?.includes("safeWriteJson.ts")) {
|
||||
// Suppress output but allow spy recording
|
||||
return
|
||||
}
|
||||
|
||||
// Pass through all other calls (from tests)
|
||||
return originalConsoleError.apply(console, args)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original behavior
|
||||
// Restore original console.error
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
|
||||
jest.useRealTimers() // Use real timers for this test suite
|
||||
|
||||
let tempTestDir: string = ""
|
||||
let currentTestFilePath = ""
|
||||
let tempDir: string
|
||||
let currentTestFilePath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a unique temporary directory for each test
|
||||
const tempDirPrefix = path.join(os.tmpdir(), "safeWriteJson-test-")
|
||||
tempTestDir = await fs.mkdtemp(tempDirPrefix)
|
||||
currentTestFilePath = path.join(tempTestDir, "test-data.json")
|
||||
// Ensure the file exists for locking purposes by default.
|
||||
// Tests that need it to not exist must explicitly unlink it.
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify({ initial: "content by beforeEach" }), "utf8")
|
||||
// Create a temporary directory for each test
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safeWriteJson-test-"))
|
||||
|
||||
// Create a unique file path for each test
|
||||
currentTestFilePath = path.join(tempDir, "test-file.json")
|
||||
|
||||
// Pre-create the file with initial content to ensure it exists
|
||||
// This allows proper-lockfile to acquire a lock on an existing file.
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify({ initial: "content" }))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempTestDir) {
|
||||
await fs.rm(tempTestDir, { recursive: true, force: true })
|
||||
tempTestDir = ""
|
||||
}
|
||||
// activeLocks is no longer used
|
||||
// Clean up the temporary directory after each test
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
|
||||
// Explicitly reset mock implementations to default (actual) behavior
|
||||
// This helps prevent state leakage between tests if spy.mockRestore() isn't fully effective
|
||||
// for functions on the module mock created by the factory.
|
||||
;(fs.writeFile as jest.Mock).mockImplementation(actualFsPromises.writeFile)
|
||||
;(fs.rename as jest.Mock).mockImplementation(actualFsPromises.rename)
|
||||
;(fs.unlink as jest.Mock).mockImplementation(actualFsPromises.unlink)
|
||||
;(fs.access as jest.Mock).mockImplementation(actualFsPromises.access)
|
||||
;(fs.readFile as jest.Mock).mockImplementation(actualFsPromises.readFile)
|
||||
;(fs.mkdtemp as jest.Mock).mockImplementation(actualFsPromises.mkdtemp)
|
||||
;(fs.rm as jest.Mock).mockImplementation(actualFsPromises.rm)
|
||||
;(fs.readdir as jest.Mock).mockImplementation(actualFsPromises.readdir)
|
||||
;(fs.mkdir as jest.Mock).mockImplementation(actualFsPromises.mkdir)
|
||||
// Ensure all mocks are reset after each test
|
||||
jest.restoreAllMocks()
|
||||
// Reset all mocks to their actual implementations
|
||||
;(fs.writeFile as any).mockImplementation(actualFsPromises.writeFile)
|
||||
;(fs.rename as any).mockImplementation(actualFsPromises.rename)
|
||||
;(fs.unlink as any).mockImplementation(actualFsPromises.unlink)
|
||||
;(fs.access as any).mockImplementation(actualFsPromises.access)
|
||||
;(fs.readFile as any).mockImplementation(actualFsPromises.readFile)
|
||||
;(fs.mkdtemp as any).mockImplementation(actualFsPromises.mkdtemp)
|
||||
;(fs.rm as any).mockImplementation(actualFsPromises.rm)
|
||||
;(fs.readdir as any).mockImplementation(actualFsPromises.readdir)
|
||||
;(fs.mkdir as any).mockImplementation(actualFsPromises.mkdir)
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const readJsonFile = async (filePath: string): Promise<any | null> => {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf8") // Now uses the mocked fs
|
||||
return JSON.parse(content)
|
||||
} catch (error: any) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return null // File not found
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Helper function to read file content
|
||||
async function readFileContent(filePath: string): Promise<any> {
|
||||
const content = await originalFsPromisesWriteFile.call(fs, filePath, "")
|
||||
const readContent = await fs.readFile(filePath, "utf-8")
|
||||
return JSON.parse(readContent)
|
||||
}
|
||||
|
||||
const listTempFiles = async (dir: string, baseName: string): Promise<string[]> => {
|
||||
const files = await fs.readdir(dir) // Now uses the mocked fs
|
||||
return files.filter((f: string) => f.startsWith(`.${baseName}.new_`) || f.startsWith(`.${baseName}.bak_`))
|
||||
// Helper function to check if a file exists
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Success Scenarios
|
||||
// Note: With the beforeEach change, this test now effectively tests overwriting the initial file.
|
||||
// Note: Since we pre-create the file in beforeEach, this test will overwrite it.
|
||||
// If "creation from non-existence" is critical and locking prevents it, safeWriteJson or locking strategy needs review.
|
||||
test("should successfully write a new file (overwriting initial content from beforeEach)", async () => {
|
||||
const data = { message: "Hello, new world!" }
|
||||
|
||||
await safeWriteJson(currentTestFilePath, data)
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(data)
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0)
|
||||
const content = await readFileContent(currentTestFilePath)
|
||||
expect(content).toEqual(data)
|
||||
})
|
||||
|
||||
test("should successfully overwrite an existing file", async () => {
|
||||
const initialData = { message: "Initial content" }
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Now uses the mocked fs for setup
|
||||
|
||||
const newData = { message: "Updated content" }
|
||||
|
||||
// Write initial data (overwriting the pre-created file from beforeEach)
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData))
|
||||
|
||||
await safeWriteJson(currentTestFilePath, newData)
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(newData)
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0)
|
||||
const content = await readFileContent(currentTestFilePath)
|
||||
expect(content).toEqual(newData)
|
||||
})
|
||||
|
||||
// Failure Scenarios
|
||||
test("should handle failure when writing to tempNewFilePath", async () => {
|
||||
// currentTestFilePath exists due to beforeEach, allowing lock acquisition.
|
||||
const data = { message: "This should not be written" }
|
||||
const data = { message: "test write failure" }
|
||||
|
||||
const mockErrorStream = new Writable() as jest.Mocked<Writable> & { _write?: any }
|
||||
mockErrorStream._write = (_chunk: any, _encoding: any, callback: (error?: Error | null) => void) => {
|
||||
// Simulate an error during write
|
||||
callback(new Error("Simulated Stream Error: createWriteStream failed"))
|
||||
const mockErrorStream = new Writable() as any & { _write?: any }
|
||||
mockErrorStream._write = (_chunk: any, _encoding: any, callback: any) => {
|
||||
callback(new Error("Write stream error"))
|
||||
}
|
||||
|
||||
// Mock createWriteStream to simulate a failure during the streaming of data to the temp file.
|
||||
;(fsSyncActual.createWriteStream as jest.Mock).mockImplementationOnce((_path: any, _options: any) => {
|
||||
const stream = new Writable({
|
||||
write(_chunk, _encoding, cb) {
|
||||
cb(new Error("Simulated Stream Error: createWriteStream failed"))
|
||||
},
|
||||
// Ensure destroy is handled to prevent unhandled rejections in stream internals
|
||||
destroy(_error, cb) {
|
||||
if (cb) cb(_error)
|
||||
},
|
||||
})
|
||||
return stream as fsSyncActual.WriteStream
|
||||
// Mock createWriteStream to return a stream that errors on write
|
||||
;(fsSyncActual.createWriteStream as any).mockImplementationOnce((_path: any, _options: any) => {
|
||||
return mockErrorStream
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow(
|
||||
"Simulated Stream Error: createWriteStream failed",
|
||||
)
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow("Write stream error")
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
// If write to .new fails, original file (from beforeEach) should remain.
|
||||
expect(writtenData).toEqual({ initial: "content by beforeEach" })
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0) // All temp files should be cleaned up
|
||||
// Verify the original file still exists and is unchanged
|
||||
const exists = await fileExists(currentTestFilePath)
|
||||
expect(exists).toBe(true)
|
||||
|
||||
// Verify content is unchanged (should still have the initial content from beforeEach)
|
||||
const content = await readFileContent(currentTestFilePath)
|
||||
expect(content).toEqual({ initial: "content" })
|
||||
})
|
||||
|
||||
test("should handle failure when renaming filePath to tempBackupFilePath (filePath exists)", async () => {
|
||||
const initialData = { message: "Initial content, should remain" }
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData)) // Use original for setup
|
||||
const newData = { message: "New content, should not be written" }
|
||||
|
||||
const newData = { message: "This should not be written" }
|
||||
const renameSpy = jest.spyOn(fs, "rename")
|
||||
// First rename is target to backup
|
||||
renameSpy.mockImplementationOnce(async (oldPath: any, newPath: any) => {
|
||||
if (typeof newPath === "string" && newPath.includes(".bak_")) {
|
||||
throw new Error("Simulated FS Error: rename to tempBackupFilePath")
|
||||
}
|
||||
return originalFsPromisesRename(oldPath, newPath) // Use constant
|
||||
// Overwrite the pre-created file with specific initial data
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData))
|
||||
|
||||
const renameSpy = vi.spyOn(fs, "rename")
|
||||
|
||||
// Mock rename to fail on the first call (filePath -> tempBackupFilePath)
|
||||
renameSpy.mockImplementationOnce(async () => {
|
||||
throw new Error("Rename to backup failed")
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow(
|
||||
"Simulated FS Error: rename to tempBackupFilePath",
|
||||
)
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Rename to backup failed")
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(initialData) // Original file should be intact
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
// tempNewFile was created, but should be cleaned up. Backup was not created.
|
||||
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)
|
||||
expect(tempFiles.filter((f: string) => f.includes(".bak_")).length).toBe(0)
|
||||
|
||||
renameSpy.mockRestore()
|
||||
// Verify the original file still exists with initial content
|
||||
const content = await readFileContent(currentTestFilePath)
|
||||
expect(content).toEqual(initialData)
|
||||
})
|
||||
|
||||
test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup succeeded)", async () => {
|
||||
const initialData = { message: "Initial content, should be restored" }
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Use mocked fs for setup
|
||||
const newData = { message: "New content" }
|
||||
|
||||
const newData = { message: "This is in tempNewFilePath" }
|
||||
const renameSpy = jest.spyOn(fs, "rename")
|
||||
let renameCallCountTest1 = 0
|
||||
renameSpy.mockImplementation(async (oldPath: any, newPath: any) => {
|
||||
const oldPathStr = oldPath.toString()
|
||||
const newPathStr = newPath.toString()
|
||||
renameCallCountTest1++
|
||||
// Overwrite the pre-created file with specific initial data
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData))
|
||||
|
||||
// First rename call by safeWriteJson (if target exists) is target -> .bak
|
||||
if (renameCallCountTest1 === 1 && !oldPathStr.includes(".new_") && newPathStr.includes(".bak_")) {
|
||||
const renameSpy = vi.spyOn(fs, "rename")
|
||||
|
||||
// Track rename calls
|
||||
let renameCallCount = 0
|
||||
|
||||
// Mock rename to succeed on first call (filePath -> tempBackupFilePath)
|
||||
// and fail on second call (tempNewFilePath -> filePath)
|
||||
renameSpy.mockImplementation(async (oldPath, newPath) => {
|
||||
renameCallCount++
|
||||
if (renameCallCount === 1) {
|
||||
// First call: filePath -> tempBackupFilePath (should succeed)
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
} else if (renameCallCount === 2) {
|
||||
// Second call: tempNewFilePath -> filePath (should fail)
|
||||
throw new Error("Rename from temp to final failed")
|
||||
} else if (renameCallCount === 3) {
|
||||
// Third call: tempBackupFilePath -> filePath (rollback, should succeed)
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
}
|
||||
// Second rename call by safeWriteJson is .new -> target
|
||||
else if (
|
||||
renameCallCountTest1 === 2 &&
|
||||
oldPathStr.includes(".new_") &&
|
||||
path.resolve(newPathStr) === path.resolve(currentTestFilePath)
|
||||
) {
|
||||
throw new Error("Simulated FS Error: rename tempNewFilePath to filePath")
|
||||
}
|
||||
// Fallback for unexpected calls or if the target file didn't exist (only one rename: .new -> target)
|
||||
else if (
|
||||
renameCallCountTest1 === 1 &&
|
||||
oldPathStr.includes(".new_") &&
|
||||
path.resolve(newPathStr) === path.resolve(currentTestFilePath)
|
||||
) {
|
||||
// This case handles if the initial file didn't exist, so only one rename happens.
|
||||
// For this specific test, we expect two renames.
|
||||
throw new Error("Simulated FS Error: rename tempNewFilePath to filePath")
|
||||
}
|
||||
// Default: use original implementation
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
})
|
||||
|
||||
// This scenario should reject because the new data couldn't be written to the final path,
|
||||
// even if rollback succeeds.
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow(
|
||||
"Simulated FS Error: rename tempNewFilePath to filePath",
|
||||
)
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Rename from temp to final failed")
|
||||
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(initialData) // Original file should be restored from backup
|
||||
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0) // All temp/backup files should be cleaned up
|
||||
|
||||
renameSpy.mockRestore()
|
||||
// Verify the file was restored to initial content
|
||||
const content = await readFileContent(currentTestFilePath)
|
||||
expect(content).toEqual(initialData)
|
||||
})
|
||||
|
||||
// Tests for directory creation functionality
|
||||
test("should create parent directory if it doesn't exist", async () => {
|
||||
// Create a path in a non-existent subdirectory of the temp dir
|
||||
const nonExistentDir = path.join(tempTestDir, "non-existent-dir")
|
||||
const filePath = path.join(nonExistentDir, "test-data.json")
|
||||
const data = { message: "Hello from new directory" }
|
||||
const subDir = path.join(tempDir, "new-subdir")
|
||||
const filePath = path.join(subDir, "file.json")
|
||||
const data = { test: "directory creation" }
|
||||
|
||||
// Verify the directory doesn't exist yet
|
||||
const dirAccessError = await fs.access(nonExistentDir).catch((e) => e)
|
||||
expect(dirAccessError).toBeDefined()
|
||||
expect(dirAccessError.code).toBe("ENOENT")
|
||||
// Verify directory doesn't exist
|
||||
await expect(fs.access(subDir)).rejects.toThrow()
|
||||
|
||||
// safeWriteJson should now create directories and initialize an empty file automatically
|
||||
|
||||
// safeWriteJson should write the file
|
||||
// Write file
|
||||
await safeWriteJson(filePath, data)
|
||||
|
||||
// Verify file was written correctly
|
||||
const writtenData = await readJsonFile(filePath)
|
||||
expect(writtenData).toEqual(data)
|
||||
// Verify directory was created
|
||||
await expect(fs.access(subDir)).resolves.toBeUndefined()
|
||||
|
||||
// Verify no temp files remain
|
||||
const tempFiles = await listTempFiles(nonExistentDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0)
|
||||
// Verify file was written
|
||||
const content = await readFileContent(filePath)
|
||||
expect(content).toEqual(data)
|
||||
})
|
||||
|
||||
test("should handle multi-level directory creation", async () => {
|
||||
// Create a new non-existent subdirectory path with multiple levels
|
||||
const newDir = path.join(tempTestDir, "new-test-dir", "subdir", "deeper")
|
||||
const filePath = path.join(newDir, "new-file.json")
|
||||
const data = { message: "New directory test" }
|
||||
const deepDir = path.join(tempDir, "level1", "level2", "level3")
|
||||
const filePath = path.join(deepDir, "deep-file.json")
|
||||
const data = { nested: "deeply" }
|
||||
|
||||
// Verify directories don't exist initially
|
||||
const dirAccessError = await fs.access(newDir).catch((e) => e)
|
||||
expect(dirAccessError).toBeDefined()
|
||||
expect(dirAccessError.code).toBe("ENOENT")
|
||||
// Verify none of the directories exist
|
||||
await expect(fs.access(path.join(tempDir, "level1"))).rejects.toThrow()
|
||||
|
||||
// Don't create any directories - safeWriteJson should handle it all
|
||||
|
||||
// Call safeWriteJson - it should create all missing directories and the file
|
||||
// Write file
|
||||
await safeWriteJson(filePath, data)
|
||||
|
||||
// Verify all directory levels now exist
|
||||
const dirExists = await fs
|
||||
.access(newDir)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(dirExists).toBe(true)
|
||||
// Verify all directories were created
|
||||
await expect(fs.access(path.join(tempDir, "level1"))).resolves.toBeUndefined()
|
||||
await expect(fs.access(path.join(tempDir, "level1", "level2"))).resolves.toBeUndefined()
|
||||
await expect(fs.access(deepDir)).resolves.toBeUndefined()
|
||||
|
||||
// Verify file was written correctly
|
||||
const writtenData = await readJsonFile(filePath)
|
||||
expect(writtenData).toEqual(data)
|
||||
|
||||
// Check that no temp files remain
|
||||
const tempFiles = await listTempFiles(newDir, "new-file.json")
|
||||
expect(tempFiles.length).toBe(0)
|
||||
// Verify file was written
|
||||
const content = await readFileContent(filePath)
|
||||
expect(content).toEqual(data)
|
||||
})
|
||||
|
||||
test("should handle directory creation permission errors", async () => {
|
||||
// Mock mkdir to simulate a permission error
|
||||
const mkdirSpy = jest.spyOn(fs, "mkdir")
|
||||
const mkdirSpy = vi.spyOn(fs, "mkdir")
|
||||
mkdirSpy.mockImplementationOnce(async () => {
|
||||
const permError = new Error("EACCES: permission denied") as NodeJS.ErrnoException
|
||||
permError.code = "EACCES"
|
||||
throw permError
|
||||
const error = new Error("EACCES: permission denied") as any
|
||||
error.code = "EACCES"
|
||||
throw error
|
||||
})
|
||||
|
||||
// Create test file path in a directory that will fail with permission error
|
||||
const nonExistentDir = path.join(tempTestDir, "permission-denied-dir")
|
||||
const filePath = path.join(nonExistentDir, "test-data.json")
|
||||
const testData = { message: "Should not be written due to permission error" }
|
||||
const subDir = path.join(tempDir, "forbidden-dir")
|
||||
const filePath = path.join(subDir, "file.json")
|
||||
const data = { test: "permission error" }
|
||||
|
||||
// Expect the function to fail with the permission error
|
||||
await expect(safeWriteJson(filePath, testData)).rejects.toThrow(/EACCES/)
|
||||
// Should throw the permission error
|
||||
await expect(safeWriteJson(filePath, data)).rejects.toThrow("EACCES: permission denied")
|
||||
|
||||
// Verify the file was not created
|
||||
const fileExists = await fs
|
||||
.access(filePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
expect(fileExists).toBe(false)
|
||||
|
||||
mkdirSpy.mockRestore()
|
||||
// Verify directory was not created
|
||||
await expect(fs.access(subDir)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("should successfully write to a non-existent file in an existing directory", async () => {
|
||||
// Create directory but not the file
|
||||
const existingDir = path.join(tempTestDir, "existing-dir")
|
||||
await fs.mkdir(existingDir, { recursive: true })
|
||||
const subDir = path.join(tempDir, "existing-dir")
|
||||
await fs.mkdir(subDir)
|
||||
|
||||
const filePath = path.join(existingDir, "non-existent-file.json")
|
||||
const data = { message: "Creating new file" }
|
||||
const filePath = path.join(subDir, "new-file.json")
|
||||
const data = { fresh: "file" }
|
||||
|
||||
// Verify file doesn't exist before the operation
|
||||
const accessError = await fs.access(filePath).catch((e) => e)
|
||||
expect(accessError).toBeDefined()
|
||||
expect(accessError.code).toBe("ENOENT")
|
||||
// Verify file doesn't exist yet
|
||||
await expect(fs.access(filePath)).rejects.toThrow()
|
||||
|
||||
// safeWriteJson should automatically create the empty file for lock acquisition
|
||||
|
||||
// Write to the file
|
||||
// Write file
|
||||
await safeWriteJson(filePath, data)
|
||||
|
||||
// Verify file was created with correct content
|
||||
const writtenData = await readJsonFile(filePath)
|
||||
expect(writtenData).toEqual(data)
|
||||
|
||||
// Verify no temp files remain
|
||||
const tempFiles = await listTempFiles(existingDir, "non-existent-file.json")
|
||||
expect(tempFiles.length).toBe(0)
|
||||
const content = await readFileContent(filePath)
|
||||
expect(content).toEqual(data)
|
||||
})
|
||||
|
||||
test("should handle failure when deleting tempBackupFilePath (filePath exists, all renames succeed)", async () => {
|
||||
const initialData = { message: "Initial content" }
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Use mocked fs for setup
|
||||
const newData = { message: "Successfully written new content" }
|
||||
|
||||
const newData = { message: "This should be the final content" }
|
||||
const unlinkSpy = jest.spyOn(fs, "unlink")
|
||||
// The unlink that targets the backup file fails
|
||||
unlinkSpy.mockImplementationOnce(async (filePath: any) => {
|
||||
const filePathStr = filePath.toString()
|
||||
if (filePathStr.includes(".bak_")) {
|
||||
throw new Error("Simulated FS Error: delete tempBackupFilePath")
|
||||
}
|
||||
return originalFsPromisesUnlink(filePath)
|
||||
// Overwrite the pre-created file with specific initial data
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData))
|
||||
|
||||
const unlinkSpy = vi.spyOn(fs, "unlink")
|
||||
|
||||
// Mock unlink to fail when trying to delete the backup file
|
||||
unlinkSpy.mockImplementationOnce(async () => {
|
||||
throw new Error("Failed to delete backup file")
|
||||
})
|
||||
|
||||
// The function itself should still succeed from the user's perspective,
|
||||
// as the primary operation (writing the new data) was successful.
|
||||
// The error during backup cleanup is logged but not re-thrown to the caller.
|
||||
// However, the current implementation *does* re-throw. Let's test that behavior.
|
||||
// If the desired behavior is to not re-throw on backup cleanup failure, the main function needs adjustment.
|
||||
// The current safeWriteJson logic is to log the error and NOT reject.
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error
|
||||
// The write should succeed even if backup deletion fails
|
||||
await safeWriteJson(currentTestFilePath, newData)
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).resolves.toBeUndefined()
|
||||
// Verify the new content was written successfully
|
||||
const content = await readFileContent(currentTestFilePath)
|
||||
expect(content).toEqual(newData)
|
||||
})
|
||||
|
||||
// The main file should be the new data
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual(newData)
|
||||
// Test for console error suppression during backup deletion
|
||||
test("should suppress console.error when backup deletion fails", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error
|
||||
const initialData = { message: "Initial" }
|
||||
const newData = { message: "New" }
|
||||
|
||||
// Check that the cleanup failure was logged
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData))
|
||||
|
||||
// Mock unlink to fail
|
||||
const originalUnlink = fs.unlink
|
||||
;(fs.unlink as any).mockImplementationOnce(async (filePath: string) => {
|
||||
if (filePath.includes("backup")) {
|
||||
throw new Error("Backup deletion failed")
|
||||
}
|
||||
return originalUnlink(filePath)
|
||||
})
|
||||
|
||||
await safeWriteJson(currentTestFilePath, newData)
|
||||
|
||||
// Verify console.error was called with the expected message
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Successfully wrote ${currentTestFilePath}, but failed to clean up backup`),
|
||||
expect.objectContaining({ message: "Simulated FS Error: delete tempBackupFilePath" }),
|
||||
expect.stringContaining("Failed to clean up backup file"),
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
// The .new file is gone (renamed to target), the .bak file failed to delete
|
||||
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)
|
||||
expect(tempFiles.filter((f: string) => f.includes(".bak_")).length).toBe(1) // Backup file remains
|
||||
|
||||
unlinkSpy.mockRestore()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Note: With beforeEach change, currentTestFilePath will exist.
|
||||
// This test's original intent was "filePath does not exist".
|
||||
// It will now test the "filePath exists" path for the rename mock.
|
||||
// The expected error message might need to change if the mock behaves differently.
|
||||
test("should handle failure when renaming tempNewFilePath to filePath (filePath initially exists)", async () => {
|
||||
// currentTestFilePath exists due to beforeEach.
|
||||
// The original test unlinked it; we are removing that unlink to allow locking.
|
||||
const data = { message: "This should not be written" }
|
||||
const renameSpy = jest.spyOn(fs, "rename")
|
||||
const initialData = { message: "Initial content" }
|
||||
const newData = { message: "New content" }
|
||||
|
||||
// The rename from tempNew to target fails.
|
||||
// The mock needs to correctly simulate failure for the "filePath exists" case.
|
||||
// The original mock was for "no prior file".
|
||||
// For this test to be meaningful, the rename mock should simulate the failure
|
||||
// appropriately when the target file (currentTestFilePath) exists.
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData))
|
||||
|
||||
const renameSpy = vi.spyOn(fs, "rename")
|
||||
// Mock rename to fail on the second call (tempNewFilePath -> filePath)
|
||||
// This test assumes that the first rename (filePath -> tempBackupFilePath) succeeds,
|
||||
// which is the expected behavior when the file exists.
|
||||
// The existing complex mock in `test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup succeeded)"`
|
||||
// might be more relevant or adaptable here.
|
||||
// For simplicity, let's use a direct mock for the second rename call (new->target).
|
||||
let renameCallCount = 0
|
||||
renameSpy.mockImplementation(async (oldPath: any, newPath: any) => {
|
||||
renameCallCount++
|
||||
const oldPathStr = oldPath.toString()
|
||||
const newPathStr = newPath.toString()
|
||||
|
||||
if (renameCallCount === 1 && !oldPathStr.includes(".new_") && newPathStr.includes(".bak_")) {
|
||||
// Allow first rename (target to backup) to succeed
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
}
|
||||
if (
|
||||
renameCallCount === 2 &&
|
||||
oldPathStr.includes(".new_") &&
|
||||
path.resolve(newPathStr) === path.resolve(currentTestFilePath)
|
||||
) {
|
||||
// Fail the second rename (tempNew to target)
|
||||
throw new Error("Simulated FS Error: rename tempNewFilePath to existing filePath")
|
||||
let renameCallCount = 0
|
||||
renameSpy.mockImplementation(async (oldPath, newPath) => {
|
||||
renameCallCount++
|
||||
if (renameCallCount === 2) {
|
||||
// Second call: tempNewFilePath -> filePath (should fail)
|
||||
throw new Error("Rename failed")
|
||||
}
|
||||
// For all other calls, use the original implementation
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow(
|
||||
"Simulated FS Error: rename tempNewFilePath to existing filePath",
|
||||
)
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Rename failed")
|
||||
|
||||
// After failure, the original content (from beforeEach or backup) should be there.
|
||||
const writtenData = await readJsonFile(currentTestFilePath)
|
||||
expect(writtenData).toEqual({ initial: "content by beforeEach" }) // Expect restored content
|
||||
// The assertion `expect(writtenData).toBeNull()` was incorrect if rollback is successful.
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
expect(tempFiles.length).toBe(0) // All temp files should be cleaned up
|
||||
|
||||
renameSpy.mockRestore()
|
||||
// The file should be restored to its initial content
|
||||
const content = await readFileContent(currentTestFilePath)
|
||||
expect(content).toEqual(initialData)
|
||||
})
|
||||
|
||||
test("should throw an error if an inter-process lock is already held for the filePath", async () => {
|
||||
jest.resetModules() // Clear module cache to ensure fresh imports for this test
|
||||
vi.resetModules() // Clear module cache to ensure fresh imports for this test
|
||||
|
||||
const data = { message: "test lock" }
|
||||
// Ensure the resource file exists.
|
||||
await fs.writeFile(currentTestFilePath, "{}", "utf8")
|
||||
const data = { message: "test lock failure" }
|
||||
|
||||
// Temporarily mock proper-lockfile for this test only
|
||||
jest.doMock("proper-lockfile", () => ({
|
||||
...jest.requireActual("proper-lockfile"),
|
||||
lock: jest.fn().mockRejectedValueOnce(new Error("Failed to get lock.")),
|
||||
// Create a new file path for this specific test to avoid conflicts
|
||||
const lockTestFilePath = path.join(tempDir, "lock-test-file.json")
|
||||
await fs.writeFile(lockTestFilePath, JSON.stringify({ initial: "lock test content" }))
|
||||
|
||||
vi.doMock("proper-lockfile", () => ({
|
||||
...vi.importActual("proper-lockfile"),
|
||||
lock: vi.fn().mockRejectedValueOnce(new Error("Failed to get lock.")),
|
||||
}))
|
||||
|
||||
// Re-require safeWriteJson so it picks up the mocked proper-lockfile
|
||||
const { safeWriteJson: safeWriteJsonWithMockedLock } =
|
||||
require("../safeWriteJson") as typeof import("../safeWriteJson")
|
||||
// Re-import safeWriteJson to use the mocked proper-lockfile
|
||||
const { safeWriteJson: mockedSafeWriteJson } = await import("../safeWriteJson")
|
||||
|
||||
try {
|
||||
await expect(safeWriteJsonWithMockedLock(currentTestFilePath, data)).rejects.toThrow(
|
||||
/Failed to get lock.|Lock file is already being held/i,
|
||||
)
|
||||
} finally {
|
||||
jest.unmock("proper-lockfile") // Ensure the mock is removed after this test
|
||||
}
|
||||
await expect(mockedSafeWriteJson(lockTestFilePath, data)).rejects.toThrow("Failed to get lock.")
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(lockTestFilePath).catch(() => {}) // Ignore errors if file doesn't exist
|
||||
vi.unmock("proper-lockfile") // Ensure the mock is removed after this test
|
||||
})
|
||||
test("should release lock even if an error occurs mid-operation", async () => {
|
||||
const data = { message: "test lock release on error" }
|
||||
|
||||
// Mock createWriteStream to simulate a failure during the streaming of data,
|
||||
// to test if the lock is released despite this mid-operation error.
|
||||
;(fsSyncActual.createWriteStream as jest.Mock).mockImplementationOnce((_path: any, _options: any) => {
|
||||
const stream = new Writable({
|
||||
write(_chunk, _encoding, cb) {
|
||||
cb(new Error("Simulated Stream Error during mid-operation write"))
|
||||
},
|
||||
// Ensure destroy is handled
|
||||
destroy(_error, cb) {
|
||||
if (cb) cb(_error)
|
||||
},
|
||||
})
|
||||
return stream as fsSyncActual.WriteStream
|
||||
// Mock createWriteStream to throw an error
|
||||
;(fsSyncActual.createWriteStream as any).mockImplementationOnce((_path: any, _options: any) => {
|
||||
const errorStream = new Writable()
|
||||
errorStream._write = (_chunk: any, _encoding: any, callback: any) => {
|
||||
callback(new Error("Stream write error"))
|
||||
}
|
||||
return errorStream
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow(
|
||||
"Simulated Stream Error during mid-operation write",
|
||||
)
|
||||
// This should throw but still release the lock
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow("Stream write error")
|
||||
|
||||
// Lock should be released, meaning the .lock file should not exist
|
||||
const lockPath = `${path.resolve(currentTestFilePath)}.lock`
|
||||
await expect(fs.access(lockPath)).rejects.toThrow(expect.objectContaining({ code: "ENOENT" }))
|
||||
// If the lock wasn't released, this second attempt would fail with a lock error
|
||||
// Instead, it should fail with the same stream error (proving the lock was released)
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow("Stream write error")
|
||||
})
|
||||
|
||||
test("should handle fs.access error that is not ENOENT", async () => {
|
||||
const data = { message: "access error test" }
|
||||
const accessSpy = jest.spyOn(fs, "access").mockImplementationOnce(async () => {
|
||||
const err = new Error("Simulated EACCES Error") as NodeJS.ErrnoException
|
||||
err.code = "EACCES" // Simulate a permissions error, for example
|
||||
throw err
|
||||
const accessSpy = vi.spyOn(fs, "access").mockImplementationOnce(async () => {
|
||||
const error = new Error("EACCES: permission denied") as any
|
||||
error.code = "EACCES"
|
||||
throw error
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow("Simulated EACCES Error")
|
||||
// Create a path that will trigger the access check
|
||||
const testPath = path.join(tempDir, "access-error-test.json")
|
||||
|
||||
// Lock should be released, meaning the .lock file should not exist
|
||||
const lockPath = `${path.resolve(currentTestFilePath)}.lock`
|
||||
await expect(fs.access(lockPath)).rejects.toThrow(expect.objectContaining({ code: "ENOENT" }))
|
||||
await expect(safeWriteJson(testPath, data)).rejects.toThrow("EACCES: permission denied")
|
||||
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
// .new file might have been created before access check, should be cleaned up
|
||||
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)
|
||||
|
||||
accessSpy.mockRestore()
|
||||
// Verify access was called
|
||||
expect(accessSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test for rollback failure scenario
|
||||
test("should log error and re-throw original if rollback fails", async () => {
|
||||
const initialData = { message: "Initial, should be lost if rollback fails" }
|
||||
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Use mocked fs for setup
|
||||
const newData = { message: "New data" }
|
||||
const newData = { message: "New content" }
|
||||
|
||||
const renameSpy = jest.spyOn(fs, "rename")
|
||||
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error
|
||||
let renameCallCountTest2 = 0
|
||||
await originalFsPromisesWriteFile(currentTestFilePath, JSON.stringify(initialData))
|
||||
|
||||
renameSpy.mockImplementation(async (oldPath: any, newPath: any) => {
|
||||
const oldPathStr = oldPath.toString()
|
||||
const newPathStr = newPath.toString()
|
||||
renameCallCountTest2++
|
||||
const resolvedOldPath = path.resolve(oldPathStr)
|
||||
const resolvedNewPath = path.resolve(newPathStr)
|
||||
const resolvedCurrentTFP = path.resolve(currentTestFilePath)
|
||||
const renameSpy = vi.spyOn(fs, "rename")
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error
|
||||
|
||||
if (renameCallCountTest2 === 1) {
|
||||
// Call 1: Original -> Backup (Succeeds)
|
||||
if (resolvedOldPath === resolvedCurrentTFP && newPathStr.includes(".bak_")) {
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
}
|
||||
throw new Error("Unexpected args for rename call #1 in test")
|
||||
} else if (renameCallCountTest2 === 2) {
|
||||
// Call 2: New -> Original (Fails - this is the "original error")
|
||||
if (oldPathStr.includes(".new_") && resolvedNewPath === resolvedCurrentTFP) {
|
||||
throw new Error("Simulated FS Error: new to original")
|
||||
}
|
||||
throw new Error("Unexpected args for rename call #2 in test")
|
||||
} else if (renameCallCountTest2 === 3) {
|
||||
// Call 3: Backup -> Original (Rollback attempt - Fails)
|
||||
if (oldPathStr.includes(".bak_") && resolvedNewPath === resolvedCurrentTFP) {
|
||||
throw new Error("Simulated FS Error: backup to original (rollback)")
|
||||
}
|
||||
throw new Error("Unexpected args for rename call #3 in test")
|
||||
let renameCallCount = 0
|
||||
renameSpy.mockImplementation(async (oldPath, newPath) => {
|
||||
renameCallCount++
|
||||
if (renameCallCount === 2) {
|
||||
// Second call: tempNewFilePath -> filePath (fail)
|
||||
throw new Error("Primary rename failed")
|
||||
} else if (renameCallCount === 3) {
|
||||
// Third call: tempBackupFilePath -> filePath (rollback, also fail)
|
||||
throw new Error("Rollback rename failed")
|
||||
}
|
||||
return originalFsPromisesRename(oldPath, newPath)
|
||||
})
|
||||
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Simulated FS Error: new to original")
|
||||
// Should throw the original error, not the rollback error
|
||||
await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed")
|
||||
|
||||
// Check that the rollback failure was logged
|
||||
// Verify console.error was called for the rollback failure
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Operation failed for ${path.resolve(currentTestFilePath)}: [Original Error Caught]`,
|
||||
),
|
||||
expect.objectContaining({ message: "Simulated FS Error: new to original" }), // The original error
|
||||
expect.stringContaining("Failed to rollback"),
|
||||
expect.objectContaining({ message: "Rollback rename failed" }),
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\[Catch\] Failed to restore backup .*?\.bak_.*?\s+to .*?:/), // Matches the backup filename pattern
|
||||
expect.objectContaining({ message: "Simulated FS Error: backup to original (rollback)" }), // The rollback error
|
||||
)
|
||||
// The original error is logged first in safeWriteJson's catch block, then the rollback failure.
|
||||
|
||||
// File system state: original file is lost (backup couldn't be restored and was then unlinked),
|
||||
// new file was cleaned up. The target path `currentTestFilePath` should not exist.
|
||||
const finalState = await readJsonFile(currentTestFilePath)
|
||||
expect(finalState).toBeNull()
|
||||
|
||||
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
|
||||
// Backup file should also be cleaned up by the final unlink attempt in safeWriteJson's catch block,
|
||||
// as that unlink is not mocked to fail.
|
||||
expect(tempFiles.filter((f: string) => f.includes(".bak_")).length).toBe(0)
|
||||
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)
|
||||
|
||||
renameSpy.mockRestore()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue