Skip to content
This repository has been archived by the owner on Jun 16, 2018. It is now read-only.

Commit

Permalink
Merge bd14ebd into 303fb8f
Browse files Browse the repository at this point in the history
  • Loading branch information
poelstra committed Oct 12, 2017
2 parents 303fb8f + bd14ebd commit 55c2a8d
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 14 deletions.
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"start": "node localserver",
"test": "karma start --single-run --browsers Firefox --reporters dots,coverage"
"test": "jasmine server_test/*.js && karma start --single-run --browsers Firefox --reporters dots,coverage"
},
"repository": {
"type": "git",
Expand All @@ -19,12 +19,14 @@
"grunt-http-server": "^2.0.0",
"grunt-karma": "^2.0.0",
"grunt-nw-builder": "^3.0.0",
"jasmine": "^2.8.0",
"jasmine-core": "^2.4.1",
"karma": "^1.1.1",
"karma-chrome-launcher": "^2.0.0",
"karma-coverage": "^1.1.0",
"karma-firefox-launcher": "^1.0.0",
"karma-jasmine": "^1.0.2",
"mock-fs": "^4.4.1",
"phantomjs-prebuilt": "^2.1.7",
"saxon-stream2": "0.0.1"
},
Expand Down
2 changes: 2 additions & 0 deletions server_modules/args.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
var path = require('path');
var argv = require('minimist')(process.argv.slice(2));

exports.port = argv.p || 1390;
exports.basicAuthCreds = argv.u;
exports.rootdir = argv.r || path.resolve(__dirname, "..");
exports.datadir = argv.d || 'data';
exports.slaveMode = argv.s || false;
125 changes: 112 additions & 13 deletions server_modules/file_system.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,113 @@ exports.getDataFilePath = function(file) {
};

exports.resolve = function(file) {
return path.resolve(path.dirname(process.argv[1]), file);
return path.resolve(args.rootdir, file);
};

/**
* @typedef {Object} Hook
* @property {RegExp | string} pattern
* @property {(data: string, filename: string) => string | Promise<string>} callback
*/

/**
* Registry of filesystem hooks.
* @type {{ [type: string]: Hook[] }}
*/
var hooksRegistry;

/**
* Clear all register hooks, returns function to restore them.
* Useful for testing.
*/
exports.clearHooks = function () {
var oldHooks = hooksRegistry;
hooksRegistry = {
write: [],
};
return () => { hooksRegistry = oldHooks; };
};

// Initialize hooks
exports.clearHooks();

/**
* Call any hooks of the specified type (e.g. "write"), in order of registration,
* when they match the filename. First hook is called with data, subsequent hooks
* are called with result of previous hook.
* If no hooks match, the data is returned as-is (in a Promise).
*
* Filename is first made relative to args.rootdir, to make matching independent
* of location of this package.
*
* @param type {string} Type of hook (e.g. "write")
* @param filename {string} Filename to match against
* @param data {string} Data to be passed to hook
* @return {Promise<string>} Transformed data
*/
exports.callHooks = function (type, filename, data) {
const hooks = hooksRegistry[type];
if (!hooks) {
throw new Error("unknown hook type " + type);
}

// Make filename a relative path, e.g. "data/scores.json"
filename = path.relative(args.rootdir, filename);

// Start with source data
const initial = Promise.resolve(data);

// Run each hook consecutively
return hooks.reduce((intermediate, hook) => {
const pattern = hook.pattern;
const isMatch = typeof pattern === "string" ? pattern === filename : pattern.test(filename);
if (!isMatch) {
return intermediate;
}
// It matches: add another transform to the chain
return intermediate.then((data) => {
const transformed = hook.callback(data, filename);
if (typeof transformed !== "string" && (typeof transformed !== "object" || typeof transformed.then !== "function")) {
throw new Error("hook for pattern " + hook.pattern.toString() + " returned invalid data");
}
return transformed;
});
}, initial);
}

/**
* Register a hook to be called when operating on a file.
*
* Usage example:
* registerHook('write', /^foo\.txt$/, (data) => data.toUpperCase());
* or
* registerHook('write', 'foo.txt', (data) => data.toUpperCase());
*
* Then, writeFile('foo.txt', 'something') will write SOMETHING instead.
*
* Hooks are called in order of registration, and subsequent hooks will
* receive the output of the previous hook(s).
*
* Before matching and calling hooks, any filename is first made relative
* to the package's root dir (i.e. `args.rootdir`).
*
* @param type {string} Type of hook (e.g. "write")
* @param pattern {RegExp | string} Matched against filename relative to root dir (e.g. /^data\/scores\.json$/)
* @param callback {(data: string, filename: string) => string | Promise<string>}
* Called when pattern matches filename.
* Receives the data to be written (utf-8) and filename, is expected to return (promise for) optionally
* modified file data. It is an error to return nothing.
* @return void
*/
exports.registerHook = function (type, pattern, callback) {
const hooks = hooksRegistry[type];
if (!hooks) {
throw new Error("unknown hook type " + type);
}
hooks.push({
pattern,
callback
});
};

exports.readFile = function(file) {
Expand Down Expand Up @@ -51,19 +157,12 @@ exports.readFile = function(file) {
});
};

exports.writeFile = function(file, contents) {
exports.writeFile = function (file, contents) {
file = exports.resolve(file);

return Q.promise(function(resolve, reject) {
var dir = path.dirname(file);
mkdirp(dir, function(err) {
if (err) return reject(err);
fs.writeFile(file, contents, function(err) {
if(err) return reject(err);
resolve();
});
});
});
var dir = path.dirname(file);
return Q.nfcall(mkdirp, dir)
.then(() => exports.callHooks("write", file, contents))
.then((transformed) => Q.nfcall(fs.writeFile, file, transformed));
};

exports.readJsonFile = function(file) {
Expand Down
136 changes: 136 additions & 0 deletions server_test/file_systemSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
const nodeFs = require("fs");
const path = require("path");
const mockFs = require("mock-fs");

const file_system = require("../server_modules/file_system");

describe("file_system", () => {
// Create empty hooks before tests, then restore original ones after tests
let restoreHooks;
beforeEach(() => restoreHooks = file_system.clearHooks());
afterEach(() => restoreHooks());

afterEach(() => mockFs.restore());

describe("resolve", () => {
it("should resolve to a path relative to the package's root", () => {
const packageJson = require.resolve("../package.json");
expect(require(packageJson)).toBeTruthy(); // sanity check on the path above

const rootdir = path.dirname(packageJson);
expect(file_system.resolve("foo.txt")).toBe(path.resolve(rootdir, "foo.txt"));
});
});

describe("registerHook", () => {
it("allows registering hook using string", async () => {
file_system.registerHook("write", "foo.txt", (data) => data.toUpperCase());
const result = await file_system.callHooks("write", "foo.txt", "something");
expect(result).toBe("SOMETHING");
});

it("allows registering hook using regex", async () => {
file_system.registerHook("write", /^foo\.txt$/, (data) => data.toUpperCase());
const result = await file_system.callHooks("write", "foo.txt", "something");
expect(result).toBe("SOMETHING");
});

it("throws for unknown hook types", () => {
expect(() => file_system.registerHook("foo", "foo.txt", (data) => data)).toThrow();
});
});

describe("callHooks", () => {
it("returns raw data without hooks", async () => {
const result = await file_system.callHooks("write", "foo.txt", "something");
expect(result).toBe("something");
});

it("matches hooks using string, match", async () => {
file_system.registerHook("write", "foo.txt", (data) => data.toUpperCase());
const result = await file_system.callHooks("write", "foo.txt", "something");
expect(result).toBe("SOMETHING");
});

it("matches hooks using string, no match", async () => {
file_system.registerHook("write", "foo.txt", (data) => data.toUpperCase());
const result = await file_system.callHooks("write", "fooXtxt", "something");
expect(result).toBe("something");
});

it("matches hooks using regex, match", async () => {
file_system.registerHook("write", /^foo\.txt$/, (data) => data.toUpperCase());
const result = await file_system.callHooks("write", "foo.txt", "something");
expect(result).toBe("SOMETHING");
});

it("matches hooks using regex, no match", async () => {
file_system.registerHook("write", /^foo\.txt$/, (data) => data.toUpperCase());
const result = await file_system.callHooks("write", "fooXtxt", "something");
expect(result).toBe("something");
});

it("supports synchronous transforms", async () => {
file_system.registerHook("write", "foo.txt", (data) => data + data);
file_system.registerHook("write", "foo.txt", (data) => data.toUpperCase());
const result = await file_system.callHooks("write", "foo.txt", "something");
expect(result).toBe("SOMETHINGSOMETHING");
});

it("supports asynchronous transforms", async () => {
file_system.registerHook("write", "foo.txt", (data) => Promise.resolve(data + data));
file_system.registerHook("write", "foo.txt", (data) => Promise.resolve(data.toUpperCase()));
const result = await file_system.callHooks("write", "foo.txt", "something");
expect(result).toBe("SOMETHINGSOMETHING");
});

it("throws when returning nothing from hook", async () => {
file_system.registerHook("write", "foo.txt", (data) => { /* no op */ });
await file_system.callHooks("write", "foo.txt", "something").then(
() => fail("should throw"),
(e) => expect(e instanceof Error).toBe(true)
);
});

it("matches against relative paths", async () => {
file_system.registerHook("write", "data/foo.txt", (data) => data.toUpperCase());
const fullPath = file_system.resolve("data/foo.txt");
const result = await file_system.callHooks("write", fullPath, "something");
expect(result).toBe("SOMETHING");
});
});

describe("clearHooks", () => {
it("allows to clear and restore hooks", async () => {
file_system.registerHook("write", "foo.txt", () => "abc");
const restore = file_system.clearHooks();
file_system.registerHook("write", "foo.txt", () => "def");
restore();
const result = await file_system.callHooks("write", "foo.txt", "something");
expect(result).toBe("abc");
});
});

describe("writeFile", () => {
beforeEach(() => {
// Create an empty filesystem
mockFs({});
});

it("should write a file", async () => {
await file_system.writeFile("foo.txt", "something");
expect(nodeFs.readFileSync("foo.txt", "utf8")).toBe("something");
});

it("should create full path if it doesn't exist yet", async () => {
await file_system.writeFile("some/path/foo.txt", "something");
expect(nodeFs.readFileSync("some/path/foo.txt", "utf8")).toBe("something");
});

it("can be hooked", async () => {
file_system.registerHook("write", "foo.txt", (data) => data.toUpperCase());
await file_system.writeFile("foo.txt", "something");
expect(nodeFs.readFileSync("foo.txt", "utf8")).toBe("SOMETHING");
});
});
});

0 comments on commit 55c2a8d

Please sign in to comment.