diff --git a/src/app/version.js b/src/app/version.js index 37fcc07..0023563 100644 --- a/src/app/version.js +++ b/src/app/version.js @@ -5,6 +5,52 @@ export type VersionInfo = {| +minor: number, +patch: number, |}; +export type GitState = {| + +commitHash: string, + +commitTimestamp: string, // YYYYmmdd-HHMM, in commit-local time + +dirty: boolean, // does the worktree have unstaged/uncommitted changes? +|}; +export type Environment = "development" | "production" | "test"; + +/** + * Parse the given string as a `GitState`, throwing an error if it is + * not valid. The argument should be the result of calling + * `JSON.stringify` with a valid `GitState`. Thus, this is a checked + * version of `JSON.parse`. + */ +export function parseGitState(raw: ?string): GitState { + if (typeof raw !== "string") { + throw new Error("gitState: not a string: " + String(raw)); + } + const parsed: mixed = Object.freeze(JSON.parse(raw)); + if (parsed == null || typeof parsed !== "object") { + throw new Error("gitState: not a JSON object: " + String(parsed)); + } + // This intermediate variable helps out Flow's inference... + const gitState: Object = parsed; + if ( + typeof gitState.commitHash !== "string" || + typeof gitState.commitTimestamp !== "string" || + typeof gitState.dirty !== "boolean" || + Object.keys(gitState).length !== 3 + ) { + throw new Error("gitState: bad shape: " + JSON.stringify(gitState)); + } + return gitState; +} + +/** + * Parse the given string as an `Environment`, throwing an error if it + * is not valid. The input should be a valid `Environment`. + */ +export function parseEnvironment(raw: ?string): Environment { + if (raw !== "development" && raw !== "production" && raw !== "test") { + throw new Error( + "environment: " + (raw == null ? String(raw) : JSON.stringify(raw)) + ); + } + return raw; +} export const VERSION_INFO = Object.freeze({ major: 0, diff --git a/src/app/version.test.js b/src/app/version.test.js new file mode 100644 index 0000000..41c5e44 --- /dev/null +++ b/src/app/version.test.js @@ -0,0 +1,109 @@ +// @flow + +import {type Environment, parseEnvironment, parseGitState} from "./version"; + +describe("app/version", () => { + // Like `VersionInfo`, but with some extra properties that will + // shortly be added to that type. + const version = () => ({ + major: 3, + minor: 13, + patch: 37, + gitState: { + commitHash: "d0e1a2d3b4e5", + commitTimestamp: "20010203-0405", + dirty: true, + }, + environment: "test", + }); + + describe("parseGitState", () => { + it("fails given literal `undefined`", () => { + expect(() => parseGitState(undefined)).toThrow( + "gitState: not a string: undefined" + ); + }); + it("fails given literal `null`", () => { + expect(() => parseGitState(null)).toThrow("gitState: not a string: null"); + }); + it("fails given JSON `null`", () => { + expect(() => parseGitState("null")).toThrow( + "gitState: not a JSON object: null" + ); + }); + it("fails given invalid JSON", () => { + expect(() => parseGitState("wat")).toThrow( + "Unexpected token w in JSON at position 0" + ); + }); + it("fails given a JSON string", () => { + expect(() => parseGitState(JSON.stringify("wat"))).toThrow( + "gitState: not a JSON object: wat" + ); + }); + it("fails given a non-stringified `GitState`", () => { + // $ExpectFlowError + expect(() => parseGitState(version().gitState)).toThrow( + "gitState: not a string: [object Object]" + ); + }); + it("fails given a JSON object missing a property", () => { + const gitState = version().gitState; + delete gitState.dirty; + expect(() => parseGitState(JSON.stringify(gitState))).toThrow( + "gitState: bad shape: {" + ); + }); + function expectBadShape(gitState) { + expect(() => parseGitState(JSON.stringify(gitState))).toThrow( + "gitState: bad shape: {" + ); + } + it("fails given a JSON object with an extra property", () => { + expectBadShape({...version().gitState, wat: "wot"}); + }); + it("fails given a JSON object with bad `commitHash`", () => { + expectBadShape({...version().gitState, commitHash: true}); + expectBadShape({...version().gitState, commitHash: 27}); + expectBadShape({...version().gitState, commitHash: null}); + }); + it("fails given a JSON object with bad `commitTimestamp`", () => { + expectBadShape({...version().gitState, commitTimestamp: true}); + expectBadShape({...version().gitState, commitTimestamp: 27}); + expectBadShape({...version().gitState, commitTimestamp: null}); + }); + it("fails given a JSON object with bad `dirty`", () => { + expectBadShape({...version().gitState, dirty: "true"}); + expectBadShape({...version().gitState, dirty: 27}); + expectBadShape({...version().gitState, dirty: null}); + }); + it("parses a valid `GitState`", () => { + const gitState = version().gitState; + expect(parseGitState(JSON.stringify(gitState))).toEqual(gitState); + }); + }); + + describe("parseEnvironment", () => { + it("parses each of the valid environments", () => { + const allEnvs = {development: true, production: true, test: true}; + function _unused_staticCheck(x: Environment): true { + return allEnvs[x]; + } + for (const env of Object.keys(allEnvs)) { + expect(parseEnvironment(env)).toEqual(env); + } + }); + + it("fails given literal `undefined`", () => { + expect(() => parseEnvironment(undefined)).toThrow( + "environment: undefined" + ); + }); + it("fails given literal `null`", () => { + expect(() => parseEnvironment(null)).toThrow("environment: null"); + }); + it("fails given a non-environment string", () => { + expect(() => parseEnvironment("wat")).toThrow('environment: "wat"'); + }); + }); +});