import fs from "fs"
import path from "path"
import { Readable } from "stream"
import SpecDirectory, { SpecIteratee } from "./spec-directory"
import { archiveFromStream, Directory as HrxDirectory } from "node-hrx"
import SpecOptions from "./options"
import { withAsyncCleanup } from "./cleanup"
function createFileCache(dir: HrxDirectory): Record<string, string> {
const cache: Record<string, string> = {}
for (const itemName of dir) {
const subitem = dir.get(itemName)
if (subitem?.isFile()) {
cache[itemName] = subitem.body
}
}
return cache
}
function createSubdirCache(dir: HrxDirectory): Record<string, HrxDirectory> {
const cache: Record<string, HrxDirectory> = {}
for (const itemName of dir) {
const subitem = dir.get(itemName)
if (subitem?.isDirectory()) {
cache[itemName] = subitem
}
}
return cache
}
export default class VirtualDirectory extends SpecDirectory {
path: string
basePath: string
// Names of direct files in archive order
private fileNames: string[]
// Mapping from file names to file contents
private fileContents: Record<string, string>
// Names of direct subdirectories in archive order
private subdirNames: string[]
// mapping from subdir names to the HRX Directory object
private subdirCache: Record<string, HrxDirectory>
private isArchiveRoot: boolean
private modified = false
constructor(
basePath: string,
hrxDir: HrxDirectory,
root?: SpecDirectory,
parentOpts?: SpecOptions
) {
super(root, parentOpts)
this.path = path.resolve(basePath, hrxDir.path)
this.basePath = basePath
// Separate the contents of the HrxDirectory into files and subdirs.
// Since files are modifiable, we throw away the original HrxDirectory object
// to minimize the risk of trying to reference it when doing stuff with files
this.fileNames = hrxDir.list().filter((item) => hrxDir.get(item)?.isFile())
this.fileContents = createFileCache(hrxDir)
this.subdirNames = hrxDir
.list()
.filter((item) => hrxDir.get(item)?.isDirectory())
this.subdirCache = createSubdirCache(hrxDir)
this.isArchiveRoot = hrxDir.path === ""
}
// Factories
// Unarchive the given .hrx file and turn it into a spec path
static async fromArchive(
hrxPath: string,
root?: SpecDirectory,
parentOpts?: SpecOptions
): Promise<VirtualDirectory> {
const stream = fs.createReadStream(hrxPath, { encoding: "utf-8" })
const archive = await archiveFromStream(stream)
const { dir, name } = path.parse(hrxPath)
return new VirtualDirectory(
path.resolve(dir, name),
archive,
root,
parentOpts
)
}
/**
* Create a virtual directory from string contents, and an optional path.
* If no path is given (e.g. in testing), it is set to an empty string.
*/
static async fromContents(
contents: string,
path = ""
): Promise<VirtualDirectory> {
const stream = Readable.from(contents)
const archive = await archiveFromStream(stream)
// TODO where should the temp path be?
return new VirtualDirectory(path, archive)
}
// File access
async listFiles(): Promise<string[]> {
return this.fileNames
}
hasFile(filename: string): boolean {
return this.fileContents.hasOwnProperty(filename)
}
async readFile(filename: string): Promise<string> {
this.validateFile(filename, "Cannot read file")
return this.fileContents[filename]
}
async writeFile(filename: string, contents: string): Promise<void> {
this.validateFile(filename, "Cannot write file")
this.modified = true
this.fileContents[filename] = contents
if (!this.fileNames.includes(filename)) {
this.fileNames.push(filename)
}
}
async removeFile(filename: string): Promise<void> {
this.validateFile(filename, "Cannot remove file")
this.modified = true
delete this.fileContents[filename]
this.fileNames = this.fileNames.filter((f) => f !== filename)
}
// throw an error if the given filename is invalid
private validateFile(filename: string, message: string): void {
if (this.subdirCache[filename]) {
throw new Error(`${message}: ${filename} is a directory`)
}
if (filename.includes(path.sep)) {
throw new Error(`${message}: multi-level paths not supported`)
}
}
// Subdir access
async listSubdirs(): Promise<string[]> {
return this.subdirNames
}
async getSubdir(itemName: string): Promise<VirtualDirectory> {
const subitem = this.subdirCache[itemName]
if (!subitem) {
throw new Error(`Item does not exist: ${itemName}`)
}
const options = await this.options()
return new VirtualDirectory(this.basePath, subitem, this.root, options)
}
// Iteration
// Write the files that are directly part of this directory
private async writeFilesToDisk(): Promise<void> {
await fs.promises.mkdir(this.path, { recursive: true })
const files = await this.listFiles()
const writableFiles = files.filter((filename) => {
const { base, ext } = path.parse(filename)
if (base.startsWith("output")) return false
if (![".sass", ".scss", ".css"].includes(ext)) return false
return true
})
await Promise.all(
writableFiles.map(async (filename) => {
const filepath = path.resolve(this.path, filename)
await fs.promises.writeFile(filepath, await this.readFile(filename), {
encoding: "utf-8",
})
})
)
}
// To set up a virtual directory, write all files to disk
async setup(): Promise<void> {
await this.writeFilesToDisk()
const subdirs = await this.subdirs()
await Promise.all(subdirs.map((subdir) => subdir.setup()))
}
// Return true if a this virtual directory has been modified
// (i.e. through interactive mode)
private async hasModifications(): Promise<boolean> {
const subdirs = await this.subdirs()
const subdirsNeedCleanup = await Promise.all(
subdirs.map(
(subdir) =>
subdir instanceof VirtualDirectory && subdir.hasModifications()
)
)
return this.modified || subdirsNeedCleanup.some((value) => value)
}
// Perform cleanup actions after opening this directory
async cleanup(): Promise<void> {
// remove the physical directory
await fs.promises.rm(this.path, { recursive: true, force: true })
// if files were written to this directory, write to the root archive file
if (await this.hasModifications()) {
const hrx = await this.asArchive()
await fs.promises.writeFile(this.path + ".hrx", hrx, {
encoding: "utf-8",
})
}
}
async forEachTest(iteratee: SpecIteratee, only?: string[]): Promise<void> {
if (this.isArchiveRoot) {
await this.setup()
await withAsyncCleanup(
() => this.cleanup(),
async () => {
await super.forEachTest(iteratee, only)
}
)
} else {
await super.forEachTest(iteratee, only)
}
}
}