diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 9586fb7..bbbc6fa 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -1,7 +1,7 @@ settings: import/resolver: node: - extensions: [".ts"] + extensions: [".ts", '.js'] env: node: true diff --git a/jest.config.js b/jest.config.js index 09905a1..9373221 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,11 +1,12 @@ const baseConfig = { - preset: 'ts-jest', + preset: 'ts-jest/presets/js-with-ts', testEnvironment: 'node', transform: { '^.+\\.ts?$': 'ts-jest', }, - testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.ts?$', + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(ts|js)?$', moduleFileExtensions: ['js', 'ts'], + coveragePathIgnorePatterns: ['/node_modules/', 'src/internalValidation.ts'], }; const fullConfig = { diff --git a/rollup.config.js b/rollup.config.js index 330370f..6495a64 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,9 +8,11 @@ import { terser } from 'rollup-plugin-terser'; const prod = replace({ preventAssignment: true, - 'process.env.NODE_ENV': JSON.stringify('production'), + 'process.env.PROBED_INTERNAL_VALIDATION': 'undefined', + 'process.env.PROBED_USER_VALIDATION': 'undefined', }); + const cleanup = rollupCleanup({ comments: 'none', extensions: ['.ts', '.js'] }); const typescript = rollupTypescript({ tsconfig: 'tsconfig.prod.json', outDir: './dist' }); const typscriptES5 = rollupTypescript({ target: 'ES5', tsconfig: 'tsconfig.prod.json', outDir: './dist' }); diff --git a/src/Environment.ts b/src/Environment.ts index 3b11080..c12a3f1 100644 --- a/src/Environment.ts +++ b/src/Environment.ts @@ -15,6 +15,7 @@ */ import { ProbingContext } from './ApiTypes'; +import { USER_VALIDATION_ENABLED } from './userValidation'; export type DisposeOp = () => void; export interface Environment { @@ -31,23 +32,23 @@ export const pushEnv = (ctx: Environment): void => { }; export const popEnv = (): void => { - if (process.env.NODE_ENV !== 'production' && _envStack.length === 0) { - throw new Error('Environment underflow'); + if (USER_VALIDATION_ENABLED && _envStack.length === 0) { + throw new Error('PROBE_USAGE: Environment underflow'); } _currentEnv = _envStack.pop()!; }; export const useOnDispose = (op: DisposeOp): void => { - if (process.env.NODE_ENV !== 'production' && !_currentEnv) { - throw new Error('Environment underflow'); + if (USER_VALIDATION_ENABLED && !_currentEnv) { + throw new Error('PROBE_USAGE: Environment underflow'); } _currentEnv!._onDispose(op); }; export function useProbingContext(): ProbingContext { - if (process.env.NODE_ENV !== 'production' && !_currentEnv) { - throw new Error('Environment underflow'); + if (USER_VALIDATION_ENABLED && !_currentEnv) { + throw new Error('PROBE_USAGE: Environment underflow'); } return _currentEnv!._getProbingContext(); diff --git a/src/Node.ts b/src/Node.ts index ee8444d..186976c 100644 --- a/src/Node.ts +++ b/src/Node.ts @@ -16,10 +16,8 @@ import { IPNode, ProbingContext } from './ApiTypes'; import { DisposeOp } from './Environment'; - -export interface IProber { - _finalize(target: IPNode): void; -} +import { assertUnmarked, mark } from './internalValidation'; +import { IProberBase } from './internalInterfaces'; export interface NodeBuildData { _cb: (...arg: unknown[]) => unknown; @@ -27,7 +25,7 @@ export interface NodeBuildData { _next?: BaseNode; _resolveAs: BaseNode; - _prober: IProber; + _prober: IProberBase; _context: ProbingContext; } @@ -43,6 +41,8 @@ export abstract class BaseNode implements IPNode { } dispose(): void { + assertUnmarked(this, 'disposed'); + mark(this, 'disposed'); // Nodes should only ever be disposed once this._onDispose.forEach((c) => c()); } @@ -63,7 +63,8 @@ export abstract class BaseNode implements IPNode { _onDispose: DisposeOp[] = []; _buildData?: NodeBuildData; - _uniqueNodeId?: number; + + _nodeId?: number; } export class NodeImpl extends BaseNode { diff --git a/src/Prober.ts b/src/Prober.ts index 4df51c6..37495f4 100644 --- a/src/Prober.ts +++ b/src/Prober.ts @@ -11,65 +11,51 @@ import { FuncMap, ProbingContext, } from './ApiTypes'; -import { IProber, BaseNode, NodeImpl, UnwrapPNode, isPNode } from './Node'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ComponentCb = (...args: any[]) => any; - -const isIntrinsic = (cb: IKeys | ((...args: unknown[]) => unknown)): cb is IKeys => { - return typeof cb === 'string'; -}; - -let _NextUniqueNodeId = 0; - -interface NodeQueue { - _head: BaseNode; - _tail: BaseNode; -} - -interface ProberStackFrame { - _node?: BaseNode; - _disposeOps: DisposeOp[]; - _announced?: NodeQueue; -} - -class Prober implements IProber { - private _intrinsics: Partial; - private _fallback?: IntrinsicFallback; - private _stack: ProberStackFrame[] = []; - private _currentFrame: ProberStackFrame = { _disposeOps: [] }; +import { BaseNode, NodeImpl, UnwrapPNode, isPNode } from './Node'; +import { mark, unmark, assertMarked } from './internalValidation'; +import { + checkIsValidComponent, + checkIsValidFallback, + checkIsValidIntrinsics, + checkTargetNodeIsReachable, + USER_VALIDATION_ENABLED, +} from './userValidation'; +import { ComponentCb, IProber, isIntrinsic, ProberStackFrame } from './internalInterfaces'; + +export class Prober implements IProber { + _intrinsics: Partial; + _fallback?: IntrinsicFallback; + _stack: ProberStackFrame[] = []; + _currentFrame: ProberStackFrame = { _disposeOps: [] }; + _nextNodeId?: number; _onDispose(op: DisposeOp): void { + assertMarked(this, 'finalizing'); + this._currentFrame._disposeOps.push(op); } _getProbingContext(): ProbingContext { + assertMarked(this, 'finalizing'); + return this._currentFrame!._node!._buildData!._context; } constructor(intrinsics: Partial, fallback?: IntrinsicFallback) { + checkIsValidIntrinsics(intrinsics); + checkIsValidFallback(fallback); + this._intrinsics = intrinsics; this._fallback = fallback; } - _announce | ComponentCb>(what: T, ..._args: ProbedParams): AsPNode> { - const { _cb, _name } = this._getCb(what); - if (process.env.NODE_ENV !== 'production') { - if (isIntrinsic(what)) { - if (!this._intrinsics[what] && !this._fallback) { - throw Error(`"${what}" is not a registered intrinsic in this Prober`); - } - } else { - if (typeof what !== 'function') { - throw Error(`"${what}" is not a function`); - } - } - } + _announce | ComponentCb>( + component: T, + ..._args: ProbedParams + ): AsPNode> { + const { _cb, _name } = this._resolveComponent(component); const newNode = new NodeImpl>(); - if (process.env.NODE_ENV !== 'production') { - newNode._uniqueNodeId = _NextUniqueNodeId++; - } if (!this._currentFrame._announced) { this._currentFrame._announced = { _head: newNode, _tail: newNode }; @@ -78,6 +64,12 @@ class Prober implements IProber { this._currentFrame._announced._tail = newNode; } + if (USER_VALIDATION_ENABLED) { + if (!this._nextNodeId) { + this._nextNodeId = 0; + } + newNode._nodeId = this._nextNodeId++; + } newNode._buildData = { _resolveAs: newNode, _cb, @@ -91,13 +83,13 @@ class Prober implements IProber { return newNode as AsPNode>; } - _finalizeNode(node: BaseNode) { - // If a component returns a Node (as opposed to a value), then we short-circuit to the parent. - const bd = node._buildData!; - const destinationNode = bd._resolveAs; + _finalizeNode(node: BaseNode): void { + const buildData = node._buildData!; + const destinationNode = buildData._resolveAs; - this._currentFrame._node = node; - const cbResult = bd._cb(...bd._args, bd._context); + const frame = this._currentFrame; + frame._node = node; + const cbResult = buildData._cb(...buildData._args); if (isPNode(cbResult)) { if (cbResult.finalized) { @@ -109,25 +101,13 @@ class Prober implements IProber { destinationNode._result = cbResult; } - if (this._currentFrame._disposeOps.length > 0) { - destinationNode._addToDispose(this._currentFrame._disposeOps); - this._currentFrame._disposeOps = []; - } + destinationNode._addToDispose(frame._disposeOps); + frame._disposeOps = []; } _finalize(target: IPNode): void { - if (process.env.NODE_ENV === 'check') { - let lookup: BaseNode | undefined; - if (this._currentFrame._announced) { - lookup = this._currentFrame._announced._head; - } - while (lookup && lookup !== target) { - lookup = lookup._buildData!._next; - } - if (lookup !== target) { - throw Error("Can't find target from here."); - } - } + mark(this, 'finalizing'); + checkTargetNodeIsReachable(this, target); let node = this._currentFrame._announced!._head!; let end = target as BaseNode; @@ -164,18 +144,22 @@ class Prober implements IProber { this._currentFrame = this._stack.pop()!; popEnv(); + + unmark(this, 'finalizing'); } - private _getCb | ComponentCb>(what: T): { _cb: ComponentCb; _name: string } { - if (isIntrinsic(what)) { - let _cb: ComponentCb | undefined = this._intrinsics[what]; + private _resolveComponent(component: IKeys | ComponentCb): { _cb: ComponentCb; _name: string } { + checkIsValidComponent(this, component); + + if (isIntrinsic(component)) { + let _cb: ComponentCb | undefined = this._intrinsics[component]; if (!_cb) { _cb = this._fallback!; } - return { _cb: _cb!, _name: what.toString() }; + return { _cb: _cb!, _name: component.toString() }; } else { - return { _cb: what as ComponentCb, _name: (what as ComponentCb).name }; + return { _cb: component, _name: component.name }; } } } diff --git a/src/internalInterfaces.ts b/src/internalInterfaces.ts new file mode 100644 index 0000000..b9e11a0 --- /dev/null +++ b/src/internalInterfaces.ts @@ -0,0 +1,31 @@ +import { FuncMap, IKeys, IntrinsicFallback, IPNode } from './ApiTypes'; +import { DisposeOp } from './Environment'; +import { BaseNode } from './Node'; + +interface NodeQueue { + _head: BaseNode; + _tail: BaseNode; +} + +export interface ProberStackFrame { + _node?: BaseNode; + _disposeOps: DisposeOp[]; + _announced?: NodeQueue; +} + +export interface IProberBase { + _finalize(target: IPNode): void; + _currentFrame: ProberStackFrame; +} + +export interface IProber { + _intrinsics: Partial; + _fallback?: IntrinsicFallback; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ComponentCb = (...args: any[]) => any; + +export const isIntrinsic = (cb: IKeys | ((...args: unknown[]) => unknown)): cb is IKeys => { + return typeof cb === 'string'; +}; diff --git a/src/internalValidation.ts b/src/internalValidation.ts new file mode 100644 index 0000000..327cdb4 --- /dev/null +++ b/src/internalValidation.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Francois Chabot + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ATTENTION: This file does not contribute to coverage testing! + +export const INTERNAL_VALIDATION_ENABLED = process.env.PROBED_INTERNAL_VALIDATION === 'ON'; + +/** Validates the internal state of the library. If one of these fail, then there's a problem with + * either the library itself, or the library's ability to handle invalid usages. + */ +export const assertInternal = (condition: boolean, msg?: string): void => { + if (INTERNAL_VALIDATION_ENABLED && !condition) { + throw new Error(msg || 'Internal consistency failure '); + } +}; + +const markKey = (key: string): string => { + return '_probed_mark_internal_' + key; +}; + +//eslint-disable-next-line @typescript-eslint/no-explicit-any +export const assertMarked = (what: Record, key: string, msg?: string): void => { + // This check is not redundant, because markKey still gets invoked. + if (INTERNAL_VALIDATION_ENABLED) { + assertInternal(what[markKey(key)] as boolean, msg || 'expecting mark: ' + key); + } +}; + +//eslint-disable-next-line @typescript-eslint/no-explicit-any +export const assertUnmarked = (what: Record, key: string, msg?: string): void => { + // This check is not redundant, because markKey still gets invoked. + if (INTERNAL_VALIDATION_ENABLED) { + assertInternal(!what[markKey(key)] as boolean, msg || 'unexpected mark: ' + key); + } +}; + +//eslint-disable-next-line @typescript-eslint/no-explicit-any +export const mark = (what: Record, key: string): void => { + if (INTERNAL_VALIDATION_ENABLED) { + const k = markKey(key); + if (what[k]) { + what[k]++; + } else { + what[k] = 1; + } + } +}; + +//eslint-disable-next-line @typescript-eslint/no-explicit-any +export const unmark = (what: Record, key: string): void => { + if (INTERNAL_VALIDATION_ENABLED) { + assertMarked(what, key); + + const k = markKey(key); + what[k] -= 1; + if (what[k] == 0) { + delete what[k]; + } + } +}; diff --git a/src/userValidation.ts b/src/userValidation.ts new file mode 100644 index 0000000..179e72d --- /dev/null +++ b/src/userValidation.ts @@ -0,0 +1,66 @@ +import { FuncMap, IKeys, IntrinsicFallback, IPNode } from './ApiTypes'; +import { BaseNode } from './Node'; +import { IProber, ComponentCb, isIntrinsic, IProberBase } from './internalInterfaces'; + +export const USER_VALIDATION_ENABLED = process.env.PROBED_USER_VALIDATION === 'ON'; + +// This file DOES contribute to coverage testing. +// In fact, this file would be a lot nicer if we had a little assert function, +// however, we want to ensure that we have actual coverage testing of these. +export const checkTargetNodeIsReachable = (prober: IProberBase, target: IPNode): void => { + if (USER_VALIDATION_ENABLED) { + const frame = prober._currentFrame; + let lookup: BaseNode | undefined; + if (frame._announced) { + lookup = frame._announced._head; + } + while (lookup && lookup !== target) { + lookup = lookup._buildData!._next; + } + + if (lookup !== target) { + throw new Error("PROBE_USAGE: Can't find target node in current context"); + } + } +}; + +export const checkIsValidComponent = ( + prober: IProber, + component: IKeys | ComponentCb, +): void => { + if (USER_VALIDATION_ENABLED) { + if (isIntrinsic(component)) { + if (component in prober._intrinsics) { + if (typeof prober._intrinsics[component] !== 'function') { + throw new Error(`PROBE_USAGE: "${component}" is not a function`); + } + } else { + if (!prober._fallback) { + throw new Error( + `PROBE_USAGE: "${component}" is not a registered intrinsic in this Prober, and there is no fallback function`, + ); + } + } + } else { + if (typeof component !== 'function') { + throw new Error(`PROBE_USAGE: "${component}" is not a function`); + } + } + } +}; + +export const checkIsValidIntrinsics = (intrinsics: Partial): void => { + if (USER_VALIDATION_ENABLED) { + if (typeof intrinsics !== 'object') { + throw new Error('PROBE_USAGE: intrinsics table must be an object'); + } + } +}; + +export const checkIsValidFallback = (fallback?: IntrinsicFallback): void => { + if (USER_VALIDATION_ENABLED) { + if (fallback && typeof fallback !== 'function') { + throw new Error('PROBE_USAGE: fallback must be a function'); + } + } +}; diff --git a/test-dist/jest.config.js b/test-dist/jest.config.js new file mode 100644 index 0000000..8920491 --- /dev/null +++ b/test-dist/jest.config.js @@ -0,0 +1,42 @@ +const baseConfig = { + preset: 'ts-jest/presets/js-with-ts', + testEnvironment: 'node', + transform: { + '^.+\\.ts?$': 'ts-jest', + }, + testRegex: '(/__tests__/.*|(\\.|/)(disttest))\\.(ts|js)?$', + moduleFileExtensions: ['js', 'ts'], + coveragePathIgnorePatterns: ['/node_modules/', 'src/internalValidation.ts'], +}; + +const fullConfig = { + collectCoverage: !!process.env.COVERAGE, + coverageDirectory: './coverage', + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + projects: [ + { + displayName: 'prod', + setupFiles: ['./tests/setup/prod.js'], + ...baseConfig, + }, + { + displayName: 'dev', + setupFiles: ['./tests/setup/dev.js'], + ...baseConfig, + }, + { + displayName: 'check', + setupFiles: ['./tests/setup/check.js'], + ...baseConfig, + }, + ], +}; + +module.exports = fullConfig; diff --git a/test-dist/package.json b/test-dist/package.json index f74d160..0e05792 100644 --- a/test-dist/package.json +++ b/test-dist/package.json @@ -1,6 +1,6 @@ { "scripts": { - "test": "jest --verbose" + "test": "jest" }, "author": { "name": "Francois Chabot", @@ -13,14 +13,5 @@ "jest": "^26.6.3", "ts-jest": "^26.5.5", "typescript": "^4.2.4" - }, - "jest": { - "preset": "ts-jest", - "testEnvironment": "node", - "transform": { - "^.+\\.ts?$": "ts-jest" - }, - "testRegex": "(/__tests__/.*|(\\.|/)(disttest))\\.ts?$", - "moduleFileExtensions": ["js", "ts"] } } diff --git a/test-dist/tests/probe.disttest.ts b/test-dist/tests/probe.disttest.ts index 6b49d7d..8a876ce 100644 --- a/test-dist/tests/probe.disttest.ts +++ b/test-dist/tests/probe.disttest.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { probe, createProber, PNode, useOnDispose } from '@probed/core'; +import { probe, createProber, PNode, useOnDispose, Component, useProbingContext } from '@probed/core'; +import { invalidUserAction } from './utils'; describe('Basic prober', () => { it('Works with function without arguments', () => { @@ -36,54 +37,74 @@ describe('Basic prober', () => { }); it('Fails when using invalid CB', () => { - expect(() => { - //@ts-expect-error - probe(null); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => probe(null)); - expect(() => { - //@ts-ignore - probe(undefined); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => probe(undefined)); - expect(() => { - //@ts-expect-error - probe(12); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => probe(12)); - expect(() => { - //@ts-expect-error - probe(true); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => probe(true)); - expect(() => { - //@ts-expect-error - probe([]); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => probe([])); - expect(() => { - //@ts-expect-error - probe({}); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => probe({})); }); it('Fails when using an intrinsic', () => { - expect(() => { - //@ts-expect-error - probe('yo', {}); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => probe('yo', {})); - expect(() => { - //@ts-expect-error - probe('', {}); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => probe('', {})); + }); + + // These are typescript errors, but sometimes intentional in javascript... + it('Fails when not passing enough arguments', () => { + //@ts-expect-error + () => probe((v1: number, v2: number) => v1 + v2, 12); + }); + + it('Fails when passing too many arguments', () => { + //@ts-expect-error + () => () => probe((v1: number) => v1, 12, 13); }); }); describe('Prober with intrinsics', () => { - const sutProbe = createProber({ - aaa: (v: number) => v + 1, + const mapping = { + aaa: (v: number) => { + expect(useProbingContext().componentName).toBe('aaa'); + return v + 1; + }, bbb: (v: number) => v + 4, + }; + + const sutProbe = createProber(mapping); + + it('Fails when using invalid CB', () => { + //@ts-expect-error + invalidUserAction(() => sutProbe(null)); + + //@ts-expect-error + invalidUserAction(() => sutProbe(undefined)); + + //@ts-expect-error + invalidUserAction(() => sutProbe(12)); + + //@ts-expect-error + invalidUserAction(() => sutProbe(true)); + + //@ts-expect-error + invalidUserAction(() => sutProbe([])); + + //@ts-expect-error + invalidUserAction(() => sutProbe({})); }); it('Produces a payload', () => { @@ -95,15 +116,59 @@ describe('Prober with intrinsics', () => { }); it('Fails when using wrong intrinsic', () => { - expect(() => { - //@ts-expect-error - sutProbe('aab', {}); - }).toThrow(); + //@ts-expect-error + invalidUserAction(() => sutProbe('xyz', {})); + + //@ts-expect-error + invalidUserAction(() => sutProbe('aa', {})); + + //@ts-expect-error + invalidUserAction(() => sutProbe('aaaa', {})); + + //@ts-expect-error + invalidUserAction(() => sutProbe('', {})); + }); + + it('Still handles functional', () => { + const result = sutProbe((v: number) => v + 1, 1); + expect(result.result).toBe(2); + }); + + it('works with higher-order components', () => { + const HOC = (c: Component<[number], unknown>) => sutProbe(c, 12); - expect(() => { - //@ts-expect-error - sutProbe('', {}); - }).toThrow(); + expect(sutProbe(HOC, mapping.aaa).result).toBe(13); + expect(sutProbe(HOC, mapping.bbb).result).toBe(16); + }); +}); + +describe('Dynamic intrinsic lookup', () => { + it('works', () => { + interface Base { + x: string; + } + + interface Specialized extends Base { + y: number; + } + + interface TypeInfo { + aaa: (v: number) => Base; + bbb: (v: string) => Specialized; + } + + const componentImpl = (_: number | string): Base | Specialized => { + return { x: useProbingContext().componentName }; + }; + + const sutProbe = createProber({}, componentImpl); + + expect(sutProbe('aaa', 0).result.x).toBe('aaa'); + expect(sutProbe('bbb', 'allo').result.x).toBe('bbb'); + + // In this specific case, typescript should complain, but it should still technically work. + //@ts-expect-error + expect(sutProbe('ccc', 'allo').result.x).toBe('ccc'); }); }); @@ -131,6 +196,17 @@ describe('Component With dispose', () => { }); }); +describe('Component with no dispose', () => { + const component = (x: number) => x * x; + + it('Disposing is harmless', () => { + const node = probe(component, 2); + expect(node.result).toBe(4); + + node.dispose(); + }); +}); + describe('Hierarchical components', () => { const Leaf = () => { return 3; @@ -150,3 +226,101 @@ describe('Hierarchical components', () => { expect(probe(Root).result).toBe(9); }); }); + +describe('Proxy components', () => { + let disposed = 0; + + const Sub = (v: number) => { + useOnDispose(() => (disposed += 2)); + return v * v; + }; + + const Parent = (v: number) => { + useOnDispose(() => (disposed += 3)); + return probe(Sub, v); + }; + + const node = probe(Parent, 4); + const result = node.result; + + node.dispose(); + + it('Produced the correct value', () => { + expect(result).toBe(16); + }); + + it('Disposed correctly', () => { + expect(disposed).toBe(5); + }); +}); + +describe('Pre-finalized components', () => { + let disposed = 0; + + const Sub = (v: number) => { + useOnDispose(() => (disposed += 2)); + return v * v; + }; + + const HarmlessSub = (v: number) => { + return v * v; + }; + + const Parent = (v: number, c: Component<[number], T>) => { + useOnDispose(() => (disposed += 3)); + const subNode = probe(c, v); + subNode.finalize(); + + return subNode; + }; + + const node = probe(Parent, 4, Sub); + const harmlessNode = probe(Parent, 5, HarmlessSub); + + const result = node.result; + const harmlessResult = harmlessNode.result; + + node.dispose(); + harmlessNode.dispose(); + + it('Produced the correct value', () => { + expect(result).toBe(16); + expect(harmlessResult).toBe(25); + }); + + it('Disposed correctly', () => { + expect(disposed).toBe(8); + }); +}); + +describe('Weird cases', () => { + it('catches out of context finalization', () => { + const prober = createProber({}); + + //This actually takes a surprising effort to pull off... + interface TMP { + x?: PNode; + } + const tmp: TMP = {}; + + const a = prober((v: TMP) => v.x!.result, tmp); + tmp.x = prober(() => 12); + + invalidUserAction(() => { + return a.result; + }); + }); + + it('Wildly out of order valid evaluation', () => { + //This actually takes a surprising effort to pull off... + + const Comp = (x: number) => x + x; + const data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const nodes = data.map((v) => probe(Comp, v)); + + expect(nodes[5].result).toBe(10); + expect(nodes[2].result).toBe(4); + expect(nodes[9].result).toBe(18); + expect(nodes[0].result).toBe(0); + }); +}); diff --git a/test-dist/tests/setup/check.js b/test-dist/tests/setup/check.js new file mode 100644 index 0000000..a9bfcfb --- /dev/null +++ b/test-dist/tests/setup/check.js @@ -0,0 +1 @@ +process.env.PROBED_INTERNAL_VALIDATION = 'ON'; diff --git a/test-dist/tests/setup/dev.js b/test-dist/tests/setup/dev.js new file mode 100644 index 0000000..2309ee6 --- /dev/null +++ b/test-dist/tests/setup/dev.js @@ -0,0 +1 @@ +process.env.PROBED_USER_VALIDATION = 'ON'; diff --git a/test-dist/tests/setup/prod.js b/test-dist/tests/setup/prod.js new file mode 100644 index 0000000..e69de29 diff --git a/test-dist/tests/utils.ts b/test-dist/tests/utils.ts new file mode 100644 index 0000000..7176f57 --- /dev/null +++ b/test-dist/tests/utils.ts @@ -0,0 +1,5 @@ +export const invalidUserAction = (cb: () => void): void => { + if (process.env.PROBED_USER_VALIDATION === 'ON') { + expect(cb).toThrow('PROBE_USAGE: '); + } +}; diff --git a/test-dist/tsconfig.json b/test-dist/tsconfig.json index edb4789..f1ac691 100644 --- a/test-dist/tsconfig.json +++ b/test-dist/tsconfig.json @@ -11,6 +11,7 @@ "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noUnusedLocals": true, + "allowJs": true, "lib": [ "es2018" ] diff --git a/tests/environment.test.ts b/tests/environment.test.ts index 8ab16ba..17e2ce7 100644 --- a/tests/environment.test.ts +++ b/tests/environment.test.ts @@ -1,6 +1,6 @@ import { ProbingContext } from '../src'; import { popEnv, pushEnv, useOnDispose, Environment, useProbingContext } from '../src/Environment'; -import { expectThrowInNotProd } from './utils'; +import { invalidUserAction } from './utils'; const noop = () => { // do nothing. @@ -32,7 +32,7 @@ class TestEnv implements Environment { describe('Environment', () => { it('Catches underflows', () => { - expectThrowInNotProd(popEnv); + invalidUserAction(popEnv); }); it('Registers', () => { @@ -43,8 +43,8 @@ describe('Environment', () => { expect(env.count).toBe(1); popEnv(); - expectThrowInNotProd(pingDispose); - expectThrowInNotProd(popEnv); + invalidUserAction(pingDispose); + invalidUserAction(popEnv); }); it('Maintains the stack', () => { @@ -67,14 +67,14 @@ describe('Environment', () => { expect(envB.count).toBe(1); popEnv(); - expectThrowInNotProd(pingDispose); - expectThrowInNotProd(popEnv); + invalidUserAction(pingDispose); + invalidUserAction(popEnv); }); }); describe('useProbingContext ', () => { it('Fails if used out of context', () => { - expectThrowInNotProd(useProbingContext); + invalidUserAction(useProbingContext); }); it('Works if a probing context is set', () => { diff --git a/tests/probe.test.ts b/tests/probe.test.ts index c064c21..f16b030 100644 --- a/tests/probe.test.ts +++ b/tests/probe.test.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { probe, createProber, PNode, useOnDispose, ProbingContext, Component, useProbingContext } from '../src'; -import { expectThrowInCheck, expectThrowInNotProd } from './utils'; +import { probe, createProber, PNode, useOnDispose, Component, useProbingContext } from '../src'; +import { invalidUserAction } from './utils'; describe('Basic prober', () => { it('Works with function without arguments', () => { @@ -38,35 +38,33 @@ describe('Basic prober', () => { it('Fails when using invalid CB', () => { //@ts-expect-error - expectThrowInNotProd(() => probe(null)); + invalidUserAction(() => probe(null)); //@ts-expect-error - expectThrowInNotProd(() => probe(undefined)); + invalidUserAction(() => probe(undefined)); //@ts-expect-error - expectThrowInNotProd(() => probe(12)); + invalidUserAction(() => probe(12)); //@ts-expect-error - expectThrowInNotProd(() => probe(true)); + invalidUserAction(() => probe(true)); //@ts-expect-error - expectThrowInNotProd(() => probe([])); + invalidUserAction(() => probe([])); //@ts-expect-error - expectThrowInNotProd(() => probe({})); + invalidUserAction(() => probe({})); }); it('Fails when using an intrinsic', () => { //@ts-expect-error - expectThrowInNotProd(() => probe('yo', {})); + invalidUserAction(() => probe('yo', {})); //@ts-expect-error - expectThrowInNotProd(() => probe('', {})); + invalidUserAction(() => probe('', {})); }); - // These cannot be made into runtime tests, because there's - // No way to determine at runtime if a function expects a context - // or not. + // These are typescript errors, but sometimes intentional in javascript... it('Fails when not passing enough arguments', () => { //@ts-expect-error () => probe((v1: number, v2: number) => v1 + v2, 12); @@ -74,10 +72,7 @@ describe('Basic prober', () => { it('Fails when passing too many arguments', () => { //@ts-expect-error - () => expectThrowInNotProd(() => probe((v1: number) => v1, 12, 13)); - - //@ts-expect-error - () => expectThrowInNotProd(() => probe((v1: number, _ctx: ProbingContext) => v1, 12, 13)); + () => () => probe((v1: number) => v1, 12, 13); }); }); @@ -92,6 +87,27 @@ describe('Prober with intrinsics', () => { const sutProbe = createProber(mapping); + it('Fails when using invalid CB', () => { + //@ts-expect-error + invalidUserAction(() => sutProbe(null)); + + //@ts-expect-error + invalidUserAction(() => sutProbe(undefined)); + + //@ts-expect-error + invalidUserAction(() => sutProbe(12)); + + //@ts-expect-error + invalidUserAction(() => sutProbe(true)); + + //@ts-expect-error + invalidUserAction(() => sutProbe([])); + + //@ts-expect-error + invalidUserAction(() => sutProbe({})); + }); + + it('Produces a payload', () => { const resultA = sutProbe('aaa', 1); const resultB = sutProbe('bbb', 1); @@ -102,16 +118,16 @@ describe('Prober with intrinsics', () => { it('Fails when using wrong intrinsic', () => { //@ts-expect-error - expectThrowInNotProd(() => sutProbe('xyz', {})); + invalidUserAction(() => sutProbe('xyz', {})); //@ts-expect-error - expectThrowInNotProd(() => sutProbe('aa', {})); + invalidUserAction(() => sutProbe('aa', {})); //@ts-expect-error - expectThrowInNotProd(() => sutProbe('aaaa', {})); + invalidUserAction(() => sutProbe('aaaa', {})); //@ts-expect-error - expectThrowInNotProd(() => sutProbe('', {})); + invalidUserAction(() => sutProbe('', {})); }); it('Still handles functional', () => { @@ -150,6 +166,10 @@ describe('Dynamic intrinsic lookup', () => { expect(sutProbe('aaa', 0).result.x).toBe('aaa'); expect(sutProbe('bbb', 'allo').result.x).toBe('bbb'); + + // In this specific case, typescript should complain, but it should still technically work. + //@ts-expect-error + expect(sutProbe('ccc', 'allo').result.x).toBe('ccc'); }); }); @@ -287,7 +307,7 @@ describe('Weird cases', () => { const a = prober((v: TMP) => v.x!.result, tmp); tmp.x = prober(() => 12); - expectThrowInCheck(() => { + invalidUserAction(() => { return a.result; }); }); diff --git a/tests/setup/check.js b/tests/setup/check.js index 0494743..a9bfcfb 100644 --- a/tests/setup/check.js +++ b/tests/setup/check.js @@ -1 +1 @@ -process.env.NODE_ENV = 'check'; +process.env.PROBED_INTERNAL_VALIDATION = 'ON'; diff --git a/tests/setup/dev.js b/tests/setup/dev.js index 010a278..2309ee6 100644 --- a/tests/setup/dev.js +++ b/tests/setup/dev.js @@ -1 +1 @@ -process.env.NODE_ENV = 'development'; +process.env.PROBED_USER_VALIDATION = 'ON'; diff --git a/tests/setup/prod.js b/tests/setup/prod.js index f16904e..e69de29 100644 --- a/tests/setup/prod.js +++ b/tests/setup/prod.js @@ -1 +0,0 @@ -process.env.NODE_ENV = 'production'; diff --git a/tests/sillyUser.test.js b/tests/sillyUser.test.js new file mode 100644 index 0000000..cdb51e5 --- /dev/null +++ b/tests/sillyUser.test.js @@ -0,0 +1,35 @@ +/** + * Copyright 2021 Francois Chabot + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createProber } from '../src'; +import { invalidUserAction } from './utils'; + +describe('Bad intrinsics', () => { + it('Intercepts non-mappings', () => { + invalidUserAction(() => createProber(12)); + }); + + it('Intercepts mappings on non-functions', () => { + const sut = createProber({ aaa: 12 }); + invalidUserAction(() => { + sut('aaa'); + }); + }); + + it('Intercepts bad fallbacks', () => { + invalidUserAction(() => createProber({}, 12)); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index 6454261..8755a51 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,15 +1,7 @@ -export const expectThrowInNotProd = (cb: () => void) => { - if (process.env.NODE_ENV === 'production') { - //expect(cb).not.toThrow(); - } else { - expect(cb).toThrow(); - } -}; +import { USER_VALIDATION_ENABLED } from '../src/userValidation'; -export const expectThrowInCheck = (cb: () => void) => { - if (process.env.NODE_ENV === 'check') { - expect(cb).toThrow(); - } else { - //expect(cb).not.toThrow(); +export const invalidUserAction = (cb: () => void): void => { + if (USER_VALIDATION_ENABLED) { + expect(cb).toThrow('PROBE_USAGE: '); } }; diff --git a/tests/validation.test.ts b/tests/validation.test.ts new file mode 100644 index 0000000..2d08bc4 --- /dev/null +++ b/tests/validation.test.ts @@ -0,0 +1,96 @@ +import { + assertInternal, + assertMarked, + assertUnmarked, + INTERNAL_VALIDATION_ENABLED, + mark, + unmark, +} from '../src/internalValidation'; + +const expectThrowInValidationOnly = (cb: () => void): void => { + if (INTERNAL_VALIDATION_ENABLED) { + expect(cb).toThrow(); + } else { + expect(cb).not.toThrow(); + } +}; + +describe('Internal Validation Assertions', () => { + it('Never triggers on a passing test', () => { + expect(() => assertInternal(true)).not.toThrow(); + }); + + it('Does not trigger unless validation is enabled', () => { + if (!INTERNAL_VALIDATION_ENABLED) { + expect(() => assertInternal(false)).not.toThrow(); + } + }); + + it('Triggers if validation is enabled', () => { + if (INTERNAL_VALIDATION_ENABLED) { + expect(() => assertInternal(false)).toThrow(); + } + }); +}); + +describe('Object Marking', () => { + it('Marks correctly', () => { + const x = { aaa: 'hello' }; + + expectThrowInValidationOnly(() => assertMarked(x, 'hi')); + expect(() => assertUnmarked(x, 'hi')).not.toThrow(); + + mark(x, 'hi'); + expect(() => assertMarked(x, 'hi')).not.toThrow(); + expectThrowInValidationOnly(() => assertUnmarked(x, 'hi')); + + unmark(x, 'hi'); + expectThrowInValidationOnly(() => assertMarked(x, 'hi')); + expect(() => assertUnmarked(x, 'hi')).not.toThrow(); + }); + + it('Marks recursively', () => { + const x = { aaa: 'hello' }; + + expectThrowInValidationOnly(() => assertMarked(x, 'hi')); + expect(() => assertUnmarked(x, 'hi')).not.toThrow(); + + mark(x, 'hi'); + expect(() => assertMarked(x, 'hi')).not.toThrow(); + expectThrowInValidationOnly(() => assertUnmarked(x, 'hi')); + + mark(x, 'hi'); + expect(() => assertMarked(x, 'hi')).not.toThrow(); + expectThrowInValidationOnly(() => assertUnmarked(x, 'hi')); + + unmark(x, 'hi'); + expect(() => assertMarked(x, 'hi')).not.toThrow(); + expectThrowInValidationOnly(() => assertUnmarked(x, 'hi')); + + unmark(x, 'hi'); + expectThrowInValidationOnly(() => assertMarked(x, 'hi')); + expect(() => assertUnmarked(x, 'hi')).not.toThrow(); + }); + + it('Does not comflict with object', () => { + const x = { aaa: 'hello' }; + + mark(x, 'aaa'); + expect(x.aaa).toBe('hello'); + + unmark(x, 'aaa'); + expect(x.aaa).toBe('hello'); + }); + + it('Triggers an error on underflow', () => { + const x = { aaa: 'hello' }; + + expectThrowInValidationOnly(() => unmark(x, 'hi')); + }); + + it('Triggers if validation is enabled', () => { + if (INTERNAL_VALIDATION_ENABLED) { + expect(() => assertInternal(false)).toThrow(); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 600b049..c4df733 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, "noUnusedLocals": true, + "allowJs": true, "lib": [ "ES2020" ]