diff --git a/README.md b/README.md index abc18d3a..16c3ae3a 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,9 @@ When coming up with test ID strings: ## Documentation -You would never skip reading the docs for a new language you are asked to learn, so _please_ don't skip over reading these, either. +You would never skip reading the docs for a new language you are asked to learn, +so _please_ don't skip over reading [our API documentation][api-docs] either. -+ In-depth Dart doc comments for components props and utilities are available, for use when browsing and autocompleting code in an IDE. ## Contributing @@ -127,5 +127,6 @@ The `over_react_test` library adheres to [Semantic Versioning](http://semver.org +[api-docs]: https://www.dartdocs.org/documentation/over_react_test/1.0.0/over_react_test/over_react_test-library.html [contributing-docs]: https://github.com/Workiva/over_react/blob/master/.github/CONTRIBUTING.md [over-react]: https://github.com/Workiva/over_react diff --git a/analysis_options.yaml b/analysis_options.yaml index 970a955e..b37f8f46 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -34,7 +34,7 @@ linter: - no_adjacent_strings_in_list - no_duplicate_case_values # - non_constant_identifier_names - - omit_local_variable_types + # - omit_local_variable_types # - one_member_abstracts - only_throw_errors # - overridden_fields @@ -46,10 +46,10 @@ linter: - prefer_collection_literals - prefer_const_constructors - prefer_contains - - prefer_expression_function_bodies + # - prefer_expression_function_bodies # - prefer_final_fields # - prefer_final_locals - - prefer_function_declarations_over_variables + # - prefer_function_declarations_over_variables - prefer_initializing_formals - prefer_interpolation_to_compose_strings - prefer_is_empty diff --git a/lib/over_react_test.dart b/lib/over_react_test.dart index 4e5c01ed..5f138fe3 100644 --- a/lib/over_react_test.dart +++ b/lib/over_react_test.dart @@ -12,8 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +export 'src/over_react_test/common_component_util.dart'; export 'src/over_react_test/custom_matchers.dart'; export 'src/over_react_test/dom_util.dart'; +export 'src/over_react_test/jacket.dart'; +export 'src/over_react_test/js_component.dart'; export 'src/over_react_test/react_util.dart'; +export 'src/over_react_test/validation_util.dart'; export 'src/over_react_test/wrapper_component.dart'; -export 'src/over_react_test/jacket.dart'; +export 'src/over_react_test/zone_util.dart'; diff --git a/lib/src/over_react_test/common_component_util.dart b/lib/src/over_react_test/common_component_util.dart new file mode 100644 index 00000000..afa9730c --- /dev/null +++ b/lib/src/over_react_test/common_component_util.dart @@ -0,0 +1,536 @@ +// Copyright 2017 Workiva Inc. +// +// 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 'dart:collection'; +import 'dart:html'; +// Tell dart2js that this library only needs to reflect types annotated with `Props`. +// This speeds up compilation and makes JS output much smaller. +@MirrorsUsed(metaTargets: const [ + 'over_react.component_declaration.annotations.Props' +]) +import 'dart:mirrors'; + +import 'package:over_react/over_react.dart'; +import 'package:react/react_client.dart'; +import 'package:react/react_client/react_interop.dart'; +import 'package:react/react_test_utils.dart' as react_test_utils; +import 'package:test/test.dart'; + +import './custom_matchers.dart'; +import './react_util.dart'; + +/// Run common component tests around default props, prop forwarding, class name merging, and class name overrides. +/// +/// Best used within a group() within a component's test suite: +/// +/// main() { +/// group('SomeComponent', () { +/// // other tests +/// +/// group('common component functionality:', () { +/// commonComponentTests(SomeComponentFactory); +/// }); +/// }); +/// } +/// +/// __Options:__ +/// +/// > __[shouldTestPropForwarding]__ - whether [testPropForwarding] will be called. +/// > +/// > Optionally, set [ignoreDomProps] to false if you want to test the forwarding of keys found within [DomProps]. +/// +/// > __[shouldTestRequiredProps]__ - whether [testRequiredProps] will be called. +/// +/// > __[shouldTestClassNameMerging]__ - whether [testClassNameMerging] will be called. +/// +/// > __[shouldTestClassNameOverrides]__ - whether [testClassNameOverrides] will be called. +/// +/// > [childrenFactory] returns children to be used when rendering components. +/// > +/// > This is necessary for components that need children to render properly. +/// +/// > __[unconsumedPropKeys]__ should be used when a component has props as part of it's definition that ARE forwarded +/// to its children _(ie, a smart component wrapping a primitive and forwarding some props to it)_. +/// > +/// > By default, [testPropForwarding] tests that all consumed props are not forwarded, so you can specify +/// forwarding props in [unconsumedPropKeys] _(which gets flattened into a 1D array of strings)_. +/// > +/// > When the forwarding of certain props is ambiguous (see error message in [testPropForwarding]), you can resolve +/// this by specifying [nonDefaultForwardingTestProps], a map of prop values that aren't the same as the forwarding +/// target's defaults. +/// > +/// > If [nonDefaultForwardingTestProps] can't be used for some reason, you can skip prop forwarding tests altogether +/// for certain props by specifying their keys in [skippedPropKeys] _(which gets flattened into a 1D array of strings)_. +void commonComponentTests(BuilderOnlyUiFactory factory, { + bool shouldTestPropForwarding: true, + List unconsumedPropKeys: const [], + List skippedPropKeys: const [], + Map nonDefaultForwardingTestProps: const {}, + bool shouldTestClassNameMerging: true, + bool shouldTestClassNameOverrides: true, + bool ignoreDomProps: true, + bool shouldTestRequiredProps: true, + dynamic childrenFactory() +}) { + childrenFactory ??= _defaultChildrenFactory; + + Iterable flatten(Iterable iterable) => + iterable.expand((item) => item is Iterable ? flatten(item) : [item]); + + unconsumedPropKeys = flatten(unconsumedPropKeys).toList(); + skippedPropKeys = flatten(skippedPropKeys).toList(); + + if (shouldTestPropForwarding) { + testPropForwarding(factory, childrenFactory, + unconsumedPropKeys: unconsumedPropKeys, + ignoreDomProps: ignoreDomProps, + skippedPropKeys: skippedPropKeys, + nonDefaultForwardingTestProps: nonDefaultForwardingTestProps + ); + } + if (shouldTestClassNameMerging) { + testClassNameMerging(factory, childrenFactory); + } + if (shouldTestClassNameOverrides) { + testClassNameOverrides(factory, childrenFactory); + } + if (shouldTestRequiredProps) { + testRequiredProps(factory, childrenFactory); + } +} + +/// Adds a [setUpAll] and [tearDownAll] pair to the current group that verifies that +/// no new elements exist on the test surface after everything is done running. +/// +/// main() { +/// group('SomeComponent', () { +/// expectCleanTestSurfaceAtEnd(); +/// +/// // Additional `group`s and/or `test`s +/// }); +/// } +void expectCleanTestSurfaceAtEnd() { + Set nodesBefore; + + setUpAll(() { + nodesBefore = document.body.children.toSet(); + }); + + tearDownAll(() { + Set nodesAfter = document.body.children.toSet(); + var nodesAdded = nodesAfter.difference(nodesBefore).map((element) => element.outerHtml).toList(); + + expect(nodesAdded, isEmpty, reason: 'tests should leave the test surface clean.'); + }); +} + +// ******************************************************** +// +// Individual Test Suites +// +// Called by `commonComponentTests` +// +// ******************************************************** + +/// Common test for verifying that unconsumed props are forwarded as expected. +/// +/// > Typically not consumed standalone. Use [commonComponentTests] instead. +void testPropForwarding(BuilderOnlyUiFactory factory, dynamic childrenFactory(), { + List unconsumedPropKeys: const [], + bool ignoreDomProps: true, + List skippedPropKeys: const [], + Map nonDefaultForwardingTestProps: const {} +}) { + test('forwards unconsumed props as expected', () { + const Map extraProps = const { + // Add this so we find the right component(s) with [getForwardingTargets] later. + forwardedPropBeacon: true, + + 'data-true': true, + 'aria-true': true, + + 'data-null': null, + 'aria-null': null + }; + + const Map otherProps = const { + 'other-true': true, + 'other-null': null + }; + + const String key = 'testKeyThatShouldNotBeForwarded'; + const String ref = 'testRefThatShouldNotBeForwarded'; + + /// Get defaults from a ReactElement to account for default props and any props added by the factory. + Map defaultProps = new Map.from(getProps(factory()())) + ..remove('children'); + + // TODO: Account for alias components. + Map propsThatShouldNotGetForwarded = {} + ..addAll(new Map.fromIterable(getComponentPropKeys(factory), value: (_) => null)) + // Add defaults afterwards so that components don't blow up when they have unexpected null props. + ..addAll(defaultProps) + ..addAll(nonDefaultForwardingTestProps); + + unconsumedPropKeys.forEach(propsThatShouldNotGetForwarded.remove); + + if (ignoreDomProps) { + // Remove DomProps because they should be forwarded. + const $PropKeys(DomPropsMixin).forEach(propsThatShouldNotGetForwarded.remove); + } + + var shallowRenderer = react_test_utils.createRenderer(); + + var instance = (factory() + ..addProps(propsThatShouldNotGetForwarded) + ..addProps(extraProps) + ..addProps(otherProps) + ..key = key + ..ref = ref + )(childrenFactory()); + + shallowRenderer.render(instance); + var result = shallowRenderer.getRenderOutput(); + + var forwardingTargets = getForwardingTargets(result, shallowRendered: true); + + for (var forwardingTarget in forwardingTargets) { + Map actualProps = getProps(forwardingTarget); + + // If the forwarding target is a DOM element it will should not have invalid DOM props forwarded to it. + if (isDomElement(forwardingTarget)) { + otherProps.forEach((key, value) { + expect(actualProps, isNot(containsPair(key, value))); + }); + } else { + otherProps.forEach((key, value) { + expect(actualProps, containsPair(key, value)); + }); + } + + // Expect the target to have all forwarded props. + extraProps.forEach((key, value) { + expect(actualProps, containsPair(key, value)); + }); + + var ambiguousProps = {}; + + Set propKeysThatShouldNotGetForwarded = propsThatShouldNotGetForwarded.keys.toSet(); + // Don't test any keys specified by skippedPropKeys. + propKeysThatShouldNotGetForwarded.removeAll(skippedPropKeys); + + Set unexpectedKeys = actualProps.keys.toSet().intersection(propKeysThatShouldNotGetForwarded); + + /// Test for prop keys that both are forwarded and exist on the forwarding target's default props. + if (isDartComponent(forwardingTarget)) { + // ignore: avoid_as + var forwardingTargetDefaults = ((forwardingTarget as ReactElement).type as ReactClass).dartDefaultProps; + + var commonForwardedAndDefaults = propKeysThatShouldNotGetForwarded + .intersection(forwardingTargetDefaults.keys.toSet()); + + /// Don't count these as unexpected keys in later assertions; we'll verify them within this block. + unexpectedKeys.removeAll(commonForwardedAndDefaults); + + commonForwardedAndDefaults.forEach((propKey) { + var defaultTargetValue = forwardingTargetDefaults[propKey]; + var potentiallyForwardedValue = propsThatShouldNotGetForwarded[propKey]; + + if (defaultTargetValue != potentiallyForwardedValue) { + /// If the potentially forwarded value and the default are different, + /// we can tell whether it was forwarded. + expect(actualProps, isNot(containsPair(propKey, potentiallyForwardedValue)), + reason: 'The `$propKey` prop was forwarded when it should not have been'); + } else { + /// ...otherwise, we can't be certain that the value isn't being forwarded. + ambiguousProps[propKey] = defaultTargetValue; + } + }); + } + + expect(unexpectedKeys, isEmpty, reason: 'Should filter out all consumed props'); + + if (ambiguousProps.isNotEmpty) { + fail(unindent( + ''' + Encountered ambiguous forwarded props; some unconsumed props coincide with defaults on the forwarding target, and cannot be automatically tested. + + Try either: + - specifying `nonDefaultForwardingTestProps` as a Map with valid prop values that are different than the following: $ambiguousProps + - specifying `skippedPropKeys` with the following prop keys and testing their forwarding manually: ${ambiguousProps.keys.toList()} + ''' + )); + } + } + }); +} + +/// Common test for verifying that [DomProps.className]s are merged/blacklisted as expected. +/// +/// > Typically not consumed standalone. Use [commonComponentTests] instead. +/// +/// > Related: [testClassNameOverrides] +void testClassNameMerging(BuilderOnlyUiFactory factory, dynamic childrenFactory()) { + test('merges classes as expected', () { + var builder = factory() + ..addProp(forwardedPropBeacon, true) + ..className = 'custom-class-1 blacklisted-class-1 custom-class-2 blacklisted-class-2' + ..classNameBlacklist = 'blacklisted-class-1 blacklisted-class-2'; + + var renderedInstance = render(builder(childrenFactory())); + Iterable forwardingTargetNodes = getForwardingTargets(renderedInstance).map(findDomNode); + + expect(forwardingTargetNodes, everyElement( + allOf( + hasClasses('custom-class-1 custom-class-2'), + excludesClasses('blacklisted-class-1 blacklisted-class-2') + ) + )); + }); + + test('adds custom classes to one and only one element', () { + const customClass = 'custom-class'; + + var renderedInstance = render( + (factory()..className = customClass)(childrenFactory()) + ); + var descendantsWithCustomClass = react_test_utils.scryRenderedDOMComponentsWithClass(renderedInstance, customClass); + + expect(descendantsWithCustomClass, hasLength(1)); + }); +} + +/// Common test for verifying that [DomProps.className]s added by the component can be +/// blacklisted by the consumer using [DomProps.classNameBlacklist]. +/// +/// > Typically not consumed standalone. Use [commonComponentTests] instead. +/// +/// > Related: [testClassNameMerging] +void testClassNameOverrides(BuilderOnlyUiFactory factory, dynamic childrenFactory()) { + /// Render a component without any overrides to get the classes added by the component. + var reactInstanceWithoutOverrides = render( + (factory() + ..addProp(forwardedPropBeacon, true) + )(childrenFactory()), + autoTearDown: false + ); + + Set classesToOverride; + var error; + + // Catch and rethrow getForwardingTargets-related errors so we can use classesToOverride in the test description, + // but still fail the test if something goes wrong. + try { + classesToOverride = getForwardingTargets(reactInstanceWithoutOverrides) + .map((target) => findDomNode(target).classes) + .expand((CssClassSet classSet) => classSet) + .toSet(); + } catch(e) { + error = e; + } + + unmount(reactInstanceWithoutOverrides); + + test('can override added class names: $classesToOverride', () { + if (error != null) { + throw error; + } + + // Override any added classes and verify that they are blacklisted properly. + var reactInstance = render( + (factory() + ..addProp(forwardedPropBeacon, true) + ..classNameBlacklist = classesToOverride.join(' ') + )(childrenFactory()) + ); + + Iterable forwardingTargetNodes = getForwardingTargets(reactInstance).map(findDomNode); + expect(forwardingTargetNodes, everyElement( + hasExactClasses('') + )); + }); +} + +/// Common test for verifying that props annotated as a [requiredProp] are validated correctly. +/// +/// > Typically not consumed standalone. Use [commonComponentTests] instead. +void testRequiredProps(BuilderOnlyUiFactory factory, dynamic childrenFactory()) { + var renderedInstance = render(factory()(childrenFactory()), autoTearDown: false); + var consumedProps = (getDartComponent(renderedInstance) as UiComponent).consumedProps; // ignore: avoid_as + unmount(renderedInstance); + + var requiredProps = []; + var nullableProps = []; + var keyToErrorMessage = {}; + + consumedProps.forEach((ConsumedProps consumedProps) { + consumedProps.props.forEach((PropDescriptor prop) { + if (prop.isRequired) { + requiredProps.add(prop.key); + } else if (prop.isNullable) { + nullableProps.add(prop.key); + } + + keyToErrorMessage[prop.key] = prop.errorMessage ?? ''; + }); + }); + + group('throws when the required prop', () { + requiredProps.forEach((String propKey) { + final reactComponentFactory = factory().componentFactory as ReactDartComponentFactoryProxy; // ignore: avoid_as + + // Props that are defined in the default props map will never not be set. + if (!reactComponentFactory.defaultProps.containsKey(propKey)) { + test('$propKey is not set', () { + var badRenderer = () => render((factory() + ..remove(propKey) + )(childrenFactory())); + + expect(badRenderer, throwsPropError_Required(propKey, keyToErrorMessage[propKey])); + }); + } + + test('$propKey is set to null', () { + var propsToAdd = {propKey: null}; + var badRenderer = () => render((factory() + ..addAll(propsToAdd) + )(childrenFactory())); + + expect(badRenderer, throwsPropError_Required(propKey, keyToErrorMessage[propKey])); + }); + }); + }); + + nullableProps.forEach((String propKey) { + test('throws when the the required, nullable prop $propKey is not set', () { + var badRenderer = () => render((factory()..remove(propKey)(childrenFactory()))); + + expect(badRenderer, throwsPropError_Required(propKey, keyToErrorMessage[propKey])); + }); + + test('does not throw when the required, nullable prop $propKey is set to null', () { + var propsToAdd = {propKey: null}; + var badRenderer = () => render((factory() + ..addAll(propsToAdd) + )(childrenFactory())); + + expect(badRenderer, returnsNormally); + }); + }); +} + +// ******************************************************** +// +// Shared Utils / Constants +// +// ******************************************************** + +/// Returns all the keys found within [UiComponent.props] on a component definition, using reflection. +Set getComponentPropKeys(BuilderOnlyUiFactory factory) { + var definition = factory(); + InstanceMirror definitionMirror = reflect(definition); + + Map members; + + // instanceMembers is not implemented for the DDC and will throw is this test is loaded even if it's not run. + try { + members = definitionMirror.type.instanceMembers; + } catch(e) { + members = {}; + } + + // Use prop getters on the props class to infer the prop keys for the component. + // Set all props to null to create key-value pairs for each prop, and then return those keys. + members.values.forEach((MethodMirror decl) { + // Filter out all members except concrete instance getters. + if (!decl.isGetter || decl.isSynthetic || decl.isStatic) { + return; + } + + Type owner = (decl.owner as ClassMirror).reflectedType; // ignore: avoid_as + if (owner != Object && + owner != UiProps && + owner != PropsMapViewMixin && + owner != MapViewMixin && + owner != MapView && + owner != ReactPropsMixin && + owner != DomPropsMixin && + owner != CssClassPropsMixin && + owner != UbiquitousDomPropsMixin + ) { + // Some of the getters won't correspond to props, and won't have setters. + // Catch resultant exceptions and move on. + try { + definitionMirror.setField(decl.simpleName, null); + } catch(_) {} + } + }); + + return definition.keys.toSet(); +} + +/// Return the components to which [UiComponent.props] have been forwarded. +/// +/// > Identified using the [forwardedPropBeacon] prop key. +List getForwardingTargets(reactInstance, {int expectedTargetCount: 1, shallowRendered: false}) { + if (!forwardedPropBeacon.startsWith('data-')) { + throw new Exception('forwardedPropBeacon must begin with "data-" so that is a valid HTML attribute.'); + } + + List forwardingTargets = []; + + if (shallowRendered) { + getTargets(root) { + var rootProps = getProps(root); + if (rootProps.containsKey(forwardedPropBeacon)) { + forwardingTargets.add(root); + } + + final children = rootProps['children']; + + if (children is List) { + flattenChildren(List _children) { + _children.forEach((_child) { + if (_child != null && isValidElement(_child)) { + getTargets(_child); + } else if (_child is List) { + flattenChildren(_child); + } + }); + } + + flattenChildren(children); + } else if (isValidElement(children)) { + getTargets(children); + } + } + + getTargets(reactInstance); + } else { + // Filter out non-DOM components (e.g., React.DOM.Button uses composite components to render) + forwardingTargets = findDescendantsWithProp(reactInstance, forwardedPropBeacon); + forwardingTargets = forwardingTargets.where(react_test_utils.isDOMComponent).toList(); + } + + if (forwardingTargets.length != expectedTargetCount) { + throw new ArgumentError('Unexpected number of forwarding targets: ${forwardingTargets.length}.'); + } + return forwardingTargets; +} + +/// Prop key for use in conjunction with [getForwardingTargets]. +const String forwardedPropBeacon = 'data-forwarding-target'; + +/// By default, render components without children. +dynamic _defaultChildrenFactory() => []; diff --git a/test/over_react_test/utils/test_js_component.dart b/lib/src/over_react_test/js_component.dart similarity index 84% rename from test/over_react_test/utils/test_js_component.dart rename to lib/src/over_react_test/js_component.dart index 53072c6a..18ccb988 100644 --- a/test/over_react_test/utils/test_js_component.dart +++ b/lib/src/over_react_test/js_component.dart @@ -13,12 +13,12 @@ // limitations under the License. import 'package:js/js.dart'; -import 'package:react/react.dart' as react; +import 'package:react/react.dart' as react show div; import 'package:react/react_client.dart'; -import 'package:react/react_client/react_interop.dart'; +import 'package:react/react_client/react_interop.dart' show React, ReactClassConfig; import 'package:react/react_client/js_interop_helpers.dart'; -/// A factory for a JS composite component. +/// A factory for a JS composite component, for use in testing. final Function testJsComponentFactory = (() { var componentClass = React.createClass(new ReactClassConfig( displayName: 'testJsComponent', diff --git a/lib/src/over_react_test/validation_util.dart b/lib/src/over_react_test/validation_util.dart new file mode 100644 index 00000000..68a76b5f --- /dev/null +++ b/lib/src/over_react_test/validation_util.dart @@ -0,0 +1,176 @@ +// Copyright 2017 Workiva Inc. +// +// 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 'package:over_react/over_react.dart'; +import 'package:test/test.dart'; + +/// Starts recording an OverReact [ValidationUtil.warn]ing. +/// +/// For use within `setUp`: +/// +/// group('emits a warning to the console', () { +/// setUp(startRecordingValidationWarnings); +/// +/// tearDown(stopRecordingValidationWarnings); +/// +/// test('when ', () { +/// // Do something that should trigger a warning +/// +/// verifyValidationWarning(/* some Matcher or String */); +/// }); +/// +/// test('unless ', () { +/// // Do something that should NOT trigger a warning +/// +/// rejectValidationWarning(/* some Matcher or String */); +/// }); +/// }, +/// // Be sure to not run these tests in JS browsers +/// // like Chrome, Firefox, etc. since the OverReact +/// // ValidationUtil.warn() method will only produce a +/// // console warning when compiled in "dev" mode. +/// testOn: '!js' +/// ); +/// +/// > Related: [stopRecordingValidationWarnings], [verifyValidationWarning], [rejectValidationWarning] +void startRecordingValidationWarnings() { + _validationWarnings = []; + ValidationUtil.onWarning = _recordValidationWarning; +} + +/// Stops recording the OverReact [ValidationUtil.warn]ings that were being +/// recorded as a result of calling [startRecordingValidationWarnings]. +/// +/// For use within `tearDown`: +/// +/// group('emits a warning to the console', () { +/// setUp(startRecordingValidationWarnings); +/// +/// tearDown(stopRecordingValidationWarnings); +/// +/// test('when ', () { +/// // Do something that should trigger a warning +/// +/// verifyValidationWarning(/* some Matcher or String */); +/// }); +/// +/// test('unless ', () { +/// // Do something that should NOT trigger a warning +/// +/// rejectValidationWarning(/* some Matcher or String */); +/// }); +/// }, +/// // Be sure to not run these tests in JS browsers +/// // like Chrome, Firefox, etc. since the OverReact +/// // ValidationUtil.warn() method will only produce a +/// // console warning when compiled in "dev" mode. +/// testOn: '!js' +/// ); +/// +/// > Related: [startRecordingValidationWarnings], [verifyValidationWarning], [rejectValidationWarning] +void stopRecordingValidationWarnings() { + _validationWarnings = null; + if (ValidationUtil.onWarning == _recordValidationWarning) { + ValidationUtil.onWarning = null; + } +} + +/// Verify that no validation warning(s) matching [warningMatcher] were logged. +/// +/// Be sure to call [startRecordingValidationWarnings] before any code that might log errors: +/// +/// group('emits a warning to the console', () { +/// setUp(startRecordingValidationWarnings); +/// +/// tearDown(stopRecordingValidationWarnings); +/// +/// test('when ', () { +/// // Do something that should trigger a warning +/// +/// verifyValidationWarning(/* some Matcher or String */); +/// }); +/// +/// test('unless ', () { +/// // Do something that should NOT trigger a warning +/// +/// rejectValidationWarning(/* some Matcher or String */); +/// }); +/// }, +/// // Be sure to not run these tests in JS browsers +/// // like Chrome, Firefox, etc. since the OverReact +/// // ValidationUtil.warn() method will only produce a +/// // console warning when compiled in "dev" mode. +/// testOn: '!js' +/// ); +/// +/// > Related: [verifyValidationWarning], [startRecordingValidationWarnings], [stopRecordingValidationWarnings] +void rejectValidationWarning(dynamic warningMatcher) { + expect(_validationWarnings, everyElement(isNot(warningMatcher)), + reason: 'Expected no recorded warnings to match: $warningMatcher' + ); +} + +/// Verify that a validation warning(s) matching [warningMatcher] were logged. +/// +/// Be sure to call [startRecordingValidationWarnings] before any code that might log errors: +/// +/// group('emits a warning to the console', () { +/// setUp(startRecordingValidationWarnings); +/// +/// tearDown(stopRecordingValidationWarnings); +/// +/// test('when ', () { +/// // Do something that should trigger a warning +/// +/// verifyValidationWarning(/* some Matcher or String */); +/// }); +/// +/// test('unless ', () { +/// // Do something that should NOT trigger a warning +/// +/// rejectValidationWarning(/* some Matcher or String */); +/// }); +/// }, +/// // Be sure to not run these tests in JS browsers +/// // like Chrome, Firefox, etc. since the OverReact +/// // ValidationUtil.warn() method will only produce a +/// // console warning when compiled in "dev" mode. +/// testOn: '!js' +/// ); +/// +/// > Related: [rejectValidationWarning], [startRecordingValidationWarnings], [stopRecordingValidationWarnings] +void verifyValidationWarning(dynamic warningMatcher) { + expect(_validationWarnings, anyElement(warningMatcher), + reason: 'Expected some recorded warning to match: $warningMatcher' + ); +} + +/// Returns the list of [ValidationUtil.warn]ings that have been recorded since +/// [startRecordingValidationWarnings] was first called. +/// +/// > Related: [clearValidationWarnings] +List getValidationWarnings() => _validationWarnings?.toList(); + +/// Clears the list of [ValidationUtil.warn]ings that have been recorded since +/// [startRecordingValidationWarnings] was first called. +/// +/// > Related: [getValidationWarnings] +void clearValidationWarnings() { + _validationWarnings.clear(); +} + +List _validationWarnings; +void _recordValidationWarning(String warningMessage) { + _validationWarnings.add(warningMessage); +} diff --git a/lib/src/over_react_test/zone_util.dart b/lib/src/over_react_test/zone_util.dart new file mode 100644 index 00000000..3645328f --- /dev/null +++ b/lib/src/over_react_test/zone_util.dart @@ -0,0 +1,46 @@ +// Copyright 2017 Workiva Inc. +// +// 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 'dart:async'; + +import 'package:test/test.dart'; + +Zone _zone; + +/// Validates that [storeZone] was called before [zonedExpect] was. +void validateZone() { + if (_zone == null) { + throw new StateError('Need to call storeZone() first.'); + } +} + +/// Store the specified _(or current if none is specified)_ [zone] +/// for use within [zonedExpect]. +void storeZone([Zone zone]) { + if (zone == null) { + zone = Zone.current; + } + _zone = zone; +} + +/// Calls [expect] in package:test/test.dart in the zone stored in [storeZone]. +/// +/// Useful for expectations in blocks called in other zones. +void zonedExpect(actual, matcher, {String reason}) { + validateZone(); + + return _zone.run(() { + expect(actual, matcher, reason: reason); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index ae626458..a7265d2d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ dependencies: matcher: ">=0.11.0 <0.13.0" over_react: "^1.14.0" react: "^3.4.0" - test: "^0.12.20+1" + test: "^0.12.24" dev_dependencies: coverage: "^0.7.2" dart_dev: "^1.7.7" diff --git a/test/over_react_test.dart b/test/over_react_test.dart index 02b83679..871c8416 100644 --- a/test/over_react_test.dart +++ b/test/over_react_test.dart @@ -19,18 +19,22 @@ import 'package:over_react/over_react.dart'; import 'package:react/react_client.dart'; import 'package:test/test.dart'; +import 'over_react_test/common_component_util_test.dart' as common_component_util_test; import 'over_react_test/custom_matchers_test.dart' as custom_matchers_test; import 'over_react_test/dom_util_test.dart' as test_util_dom_util_test; import 'over_react_test/jacket_test.dart' as jacket_test; import 'over_react_test/react_util_test.dart' as react_util_test; +import 'over_react_test/validation_util_test.dart' as validation_util_test; main() { setClientConfiguration(); enableTestMode(); + common_component_util_test.main(); custom_matchers_test.main(); test_util_dom_util_test.main(); jacket_test.main(); react_util_test.main(); + validation_util_test.main(); } diff --git a/test/over_react_test/common_component_util_test.dart b/test/over_react_test/common_component_util_test.dart new file mode 100644 index 00000000..d24149d9 --- /dev/null +++ b/test/over_react_test/common_component_util_test.dart @@ -0,0 +1,31 @@ +// Copyright 2017 Workiva Inc. +// +// 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 'package:over_react/over_react.dart'; +import 'package:test/test.dart'; +import 'package:over_react_test/over_react_test.dart'; + +import './utils/test_common_component.dart'; + +/// Main entry point for [commonComponentTests] testing +main() { + group('commonComponentTests', () { + // TODO: Improve / expand upon these tests. + group('should pass when the correct unconsumed props are specified', () { + commonComponentTests(TestCommon, unconsumedPropKeys: [ + const $PropKeys(PropsThatShouldBeForwarded), + ]); + }); + }); +} diff --git a/test/over_react_test/custom_matchers_test.dart b/test/over_react_test/custom_matchers_test.dart index b41ef4cd..ed5bed8c 100644 --- a/test/over_react_test/custom_matchers_test.dart +++ b/test/over_react_test/custom_matchers_test.dart @@ -18,8 +18,6 @@ import 'package:over_react/over_react.dart'; import 'package:test/test.dart'; import 'package:over_react_test/over_react_test.dart'; -import './utils/test_js_component.dart'; - /// Main entry point for CustomMatchers testing main() { group('CustomMatcher', () { @@ -387,7 +385,9 @@ main() { }); tearDown(() { - allAttachedNodes.forEach((node) => node.remove()); + for (var node in allAttachedNodes) { + node.remove(); + } allAttachedNodes.clear(); }); diff --git a/test/over_react_test/jacket_test.dart b/test/over_react_test/jacket_test.dart index 98df95fe..1a867cc7 100644 --- a/test/over_react_test/jacket_test.dart +++ b/test/over_react_test/jacket_test.dart @@ -18,7 +18,7 @@ import 'package:over_react/over_react.dart'; import 'package:test/test.dart'; import 'package:over_react_test/over_react_test.dart'; -/// Main entry point for DomUtil testing +/// Main entry point for TestJacket testing main() { group('mount: renders the given instance', () { group('attached to the document', () { diff --git a/test/over_react_test/react_util_test.dart b/test/over_react_test/react_util_test.dart index e961b101..5e24cb8a 100644 --- a/test/over_react_test/react_util_test.dart +++ b/test/over_react_test/react_util_test.dart @@ -15,13 +15,10 @@ import 'dart:html'; import 'package:over_react/over_react.dart'; -import 'package:react/react.dart' as react; +import 'package:over_react_test/over_react_test.dart'; import 'package:react/react_dom.dart' as react_dom; -import 'package:react/react_client.dart'; import 'package:test/test.dart'; -import 'package:over_react_test/over_react_test.dart'; -import './utils/test_js_component.dart'; import './utils/nested_component.dart'; /// Main entry point for ReactUtil testing @@ -91,7 +88,7 @@ main() { test('simulates a click on a component with additional event data', () { var flag = false; - react.SyntheticMouseEvent event; + SyntheticMouseEvent event; var renderedInstance = render((Dom.div() ..onClick = (evt) { flag = true; @@ -843,7 +840,9 @@ main() { test('by its mount node', () { var mountNode = new DivElement(); var ref; - react_dom.render(react.div({'ref': ((instance) => ref = instance)}), mountNode); + react_dom.render((Dom.div() + ..ref = ((instance) => ref = instance) + )(), mountNode); expect(ref, isNotNull); unmount(mountNode); @@ -861,7 +860,9 @@ main() { test('a non-mounted React instance', () { var mountNode = new DivElement(); var ref; - var instance = react_dom.render(react.div({'ref': ((instance) => ref = instance)}), mountNode); + var instance = react_dom.render((Dom.div() + ..ref = ((instance) => ref = instance) + )(), mountNode); react_dom.unmountComponentAtNode(mountNode); expect(ref, isNull); diff --git a/test/over_react_test/utils/nested_component.dart b/test/over_react_test/utils/nested_component.dart index 1bf7af94..b26a5ca2 100644 --- a/test/over_react_test/utils/nested_component.dart +++ b/test/over_react_test/utils/nested_component.dart @@ -23,7 +23,7 @@ class NestedProps extends UiProps {} @Component() class NestedComponent extends UiComponent { @override - render() { + render() { return (Dom.div()..addTestId('outer'))( (Dom.div() ..addProps(copyUnconsumedProps()) diff --git a/test/over_react_test/utils/test_common_component.dart b/test/over_react_test/utils/test_common_component.dart new file mode 100644 index 00000000..51181804 --- /dev/null +++ b/test/over_react_test/utils/test_common_component.dart @@ -0,0 +1,54 @@ +// Copyright 2017 Workiva Inc. +// +// 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 'package:over_react/over_react.dart'; + +import './test_common_component_nested.dart'; + +@Factory() +UiFactory TestCommon; + +@Props() +class TestCommonProps extends UiProps with PropsThatShouldBeForwarded, PropsThatShouldNotBeForwarded {} + +@Component(subtypeOf: TestCommonNestedComponent) +class TestCommonComponent extends UiComponent { + @override + get consumedProps => const [ + const $Props(TestCommonProps), + const $Props(PropsThatShouldNotBeForwarded) + ]; + + @override + render() { + return (TestCommonNested() + ..addProps(copyUnconsumedProps()) + ..className = forwardingClassNameBuilder().toClassName() + )(props.children); + } +} + +@PropsMixin() +abstract class PropsThatShouldBeForwarded { + Map get props; + + bool foo; +} + +@PropsMixin() +abstract class PropsThatShouldNotBeForwarded { + Map get props; + + bool bar; +} diff --git a/test/over_react_test/utils/test_common_component_nested.dart b/test/over_react_test/utils/test_common_component_nested.dart new file mode 100644 index 00000000..c0329071 --- /dev/null +++ b/test/over_react_test/utils/test_common_component_nested.dart @@ -0,0 +1,34 @@ +// Copyright 2017 Workiva Inc. +// +// 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 'package:over_react/over_react.dart'; + +import './test_common_component.dart'; +import './test_common_component_nested2.dart'; + +@Factory() +UiFactory TestCommonNested; + +@Props() +class TestCommonNestedProps extends UiProps with PropsThatShouldBeForwarded {} + +@Component() +class TestCommonNestedComponent extends UiComponent { + @override + render() { + return (TestCommonNested2() + ..addProps(copyUnconsumedProps()) + )(props.children); + } +} diff --git a/test/over_react_test/utils/test_common_component_nested2.dart b/test/over_react_test/utils/test_common_component_nested2.dart new file mode 100644 index 00000000..3589b813 --- /dev/null +++ b/test/over_react_test/utils/test_common_component_nested2.dart @@ -0,0 +1,31 @@ +// Copyright 2017 Workiva Inc. +// +// 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 'package:over_react/over_react.dart'; + +@Factory() +UiFactory TestCommonNested2; + +@Props() +class TestCommonNested2Props extends UiProps {} + +@Component() +class TestCommonNested2Component extends UiComponent { + @override + render() { + return (Dom.div() + ..addProps(copyUnconsumedDomProps()) + )(props.children); + } +} diff --git a/test/over_react_test/validation_util_test.dart b/test/over_react_test/validation_util_test.dart new file mode 100644 index 00000000..d217ef5c --- /dev/null +++ b/test/over_react_test/validation_util_test.dart @@ -0,0 +1,108 @@ +// Copyright 2017 Workiva Inc. +// +// 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 'package:over_react/over_react.dart'; +import 'package:over_react_test/over_react_test.dart'; +import 'package:test/test.dart'; + +/// Main entry point for `validation_util.dart` testing. +main() { + group('ValidationUtil:', () { + setUp(() { + startRecordingValidationWarnings(); + }); + + tearDown(() { + stopRecordingValidationWarnings(); + }); + + group('startRecordingValidationWarnings()', () { + test('begins recording validation warnings, appending them to `_validationWarnings` as expected', () { + assert(ValidationUtil.warn('message1')); + + expect(getValidationWarnings(), hasLength(1)); + expect(getValidationWarnings().single, 'message1'); + + assert(ValidationUtil.warn('message2')); + + expect(getValidationWarnings(), hasLength(2)); + expect(getValidationWarnings(), equals(['message1', 'message2'])); + }); + }); + + group('stopRecordingValidationWarnings()', () { + test('halts the recording of validation warnings and clears the list of warnings as expected', () { + assert(ValidationUtil.warn('message1')); + + expect(getValidationWarnings(), hasLength(1)); + expect(getValidationWarnings().single, 'message1'); + + stopRecordingValidationWarnings(); + + assert(ValidationUtil.warn('message2')); + + expect(getValidationWarnings(), isNull); + }); + }); + + group('verifyValidationWarning() works as expected', () { + test('when a single warning has been emitted', () { + assert(ValidationUtil.warn('message1')); + + verifyValidationWarning('message1'); + }); + + test('when multiple warnings have been emitted', () { + assert(ValidationUtil.warn('message1')); + assert(ValidationUtil.warn('message2')); + + verifyValidationWarning('message1'); + verifyValidationWarning(contains('message2')); + }); + }); + + group('rejectValidationWarning() works as expected', () { + test('when a single warning has been emitted', () { + assert(ValidationUtil.warn('message1')); + + rejectValidationWarning('nope'); + }); + + test('when multiple warnings have been emitted', () { + assert(ValidationUtil.warn('message1')); + assert(ValidationUtil.warn('message2')); + + rejectValidationWarning(contains('non-existent warning message')); + }); + }); + + group('clearValidationWarnings()', () { + test('clears the list of warnings as expected', () { + assert(ValidationUtil.warn('message1')); + + expect(getValidationWarnings(), hasLength(1)); + expect(getValidationWarnings().single, 'message1'); + + clearValidationWarnings(); + + expect(getValidationWarnings(), isEmpty); + + assert(ValidationUtil.warn('message2')); + + expect(getValidationWarnings(), hasLength(1)); + expect(getValidationWarnings().single, 'message2'); + }); + }); + }, testOn: '!js'); +} diff --git a/tool/dev.dart b/tool/dev.dart index f1723e70..0abd0c39 100644 --- a/tool/dev.dart +++ b/tool/dev.dart @@ -29,8 +29,6 @@ main(List args) async { ..platforms = [ 'content-shell', ] - // Prevent test load timeouts. - ..concurrency = 1 ..unitTests = [ 'test/over_react_test.dart', ];