| // Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| // ignore_for_file: avoid_dynamic_calls |
| |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:json_schema/json_schema.dart'; |
| import 'package:test/test.dart'; |
| |
| export '../helpers.dart' show findPackageRoot; |
| |
| typedef AllSchemas = Map<Uri, JsonSchema>; |
| |
| /// The schemas are reused in tests, so load and parse them all. |
| AllSchemas loadSchemas(List<Uri> directories) { |
| final allSchemaJsons = <Uri, Map<String, dynamic>>{}; |
| for (final dirUri in directories) { |
| Directory.fromUri(dirUri).listSync(recursive: true).forEach((file) { |
| if (file is File && file.path.endsWith('.schema.json')) { |
| final json = |
| jsonDecode(file.readAsStringSync()) as Map<String, Object?>; |
| allSchemaJsons[file.uri] = json; |
| } |
| }); |
| } |
| final allSchemas = <Uri, JsonSchema>{}; |
| for (final entry in allSchemaJsons.entries) { |
| final schema = JsonSchema.create( |
| entry.value, |
| refProvider: RefProvider.sync((String originalRef) { |
| if (originalRef.startsWith( |
| 'https://json-schema.org/draft/2020-12/schema#', |
| )) { |
| throw UnsupportedError('This is not supported in json_schema.'); |
| } |
| // Unmangle refs |
| var ref = originalRef; |
| // https://github.com/Workiva/json_schema/issues/202 |
| if (ref.startsWith('/')) { |
| ref = ref.substring(1); |
| } |
| if (ref.startsWith('hooks/doc/schema/')) { |
| ref = '../../../../$ref'; |
| } else if (ref.startsWith('shared/')) { |
| ref = '../$ref'; |
| } else if (ref.startsWith('../../../hooks/doc/schema/')) { |
| ref = '../$ref'; |
| } |
| final x = entry.key.resolve(ref); |
| return allSchemaJsons[x]!; |
| }), |
| ); |
| allSchemas[entry.key] = schema; |
| } |
| |
| final allSchemasInverted = allSchemas.map( |
| (key, value) => MapEntry(value, key), |
| ); |
| if (allSchemas.length != allSchemasInverted.length) { |
| throw StateError( |
| 'Some schemas are not unique, try adding a unique title field.', |
| ); |
| } |
| |
| return allSchemas; |
| } |
| |
| typedef AllTestData = Map<Uri, String>; |
| |
| /// The data is modified in tests, so load but don't json decode them all. |
| AllTestData loadTestsData(Uri directory) { |
| final allTestData = <Uri, String>{}; |
| for (final file in Directory.fromUri(directory).listSync()) { |
| file as File; |
| allTestData[file.uri] = file.readAsStringSync(); |
| } |
| return allTestData; |
| } |
| |
| /// Test all [allTestData] against the schemas referred to. |
| void testAllTestData(AllSchemas allSchemas, AllTestData allTestData) { |
| for (final dataUri in allTestData.keys) { |
| final data = jsonDecode(allTestData[dataUri]!); |
| final schemaRef = data[r'$schema'] as String; |
| |
| final schemaRefs = [ |
| schemaRef, |
| // The schema should have the most restrictive one, try the other one as |
| // well. |
| if (schemaRef.contains('sdk')) schemaRef.replaceAll('sdk', 'hook'), |
| if (schemaRef.contains('hook')) schemaRef.replaceAll('hook', 'sdk'), |
| ]; |
| |
| for (final schemaRef in schemaRefs) { |
| final schemaUri = dataUri.resolve(schemaRef); |
| test('Validate $dataUri against $schemaUri', () { |
| printOnFailure(dataUri.toString()); |
| printOnFailure(schemaUri.toString()); |
| final schema = allSchemas[schemaUri]!; |
| final result = schema.validate(data); |
| for (final e in result.errors) { |
| printOnFailure(e.toString()); |
| } |
| expect(result.isValid, isTrue); |
| }); |
| } |
| } |
| } |
| |
| /// Test removing a field or modifying it. |
| /// |
| /// Changing a field to a wrong type is always expected to fail. |
| /// |
| /// Removing a field can be valid, the expectations must be passed in |
| /// [missingExpectations]. |
| void testField({ |
| required Uri schemaUri, |
| required Uri dataUri, |
| required JsonSchema schema, |
| required String data, |
| required List<Object> field, |
| required void Function(ValidationResults result) missingExpectations, |
| }) { |
| final fieldPath = field.join('.'); |
| test('$schemaUri $dataUri $fieldPath missing', () { |
| final dataDecoded = jsonDecode(data); |
| final dataToModify = _traverseJson( |
| dataDecoded, |
| field.sublist(0, field.length - 1), |
| ); |
| if (dataToModify is List) { |
| final index = field.last as int; |
| dataToModify.removeAt(index); |
| } else { |
| dataToModify.remove(field.last); |
| } |
| |
| final result = schema.validate(dataDecoded); |
| printOnFailure(result.toString()); |
| missingExpectations(result); |
| }); |
| |
| test('$schemaUri $fieldPath wrong type', () { |
| final dataDecoded = jsonDecode(data); |
| final dataToModify = _traverseJson( |
| dataDecoded, |
| field.sublist(0, field.length - 1), |
| ); |
| final originalValue = dataToModify[field.last]; |
| final wrongTypeValue = originalValue is int ? '123' : 123; |
| dataToModify[field.last] = wrongTypeValue; |
| |
| final result = schema.validate(dataDecoded); |
| expect(result.isValid, isFalse); |
| }); |
| } |
| |
| void expectRequiredFieldMissing(ValidationResults result) { |
| expect(result.isValid, isFalse); |
| } |
| |
| void expectOptionalFieldMissing(ValidationResults result) { |
| expect(result.isValid, isTrue); |
| } |
| |
| typedef FieldsReturn = |
| List<(List<Object>, void Function(ValidationResults result))>; |
| typedef FieldsFunction = |
| FieldsReturn Function({ |
| required InputOrOutput inputOrOutput, |
| required Hook hook, |
| required Party party, |
| }); |
| |
| enum InputOrOutput { input, output } |
| |
| enum Hook { build, link } |
| |
| enum Party { sdk, hook } |
| |
| void testFields({ |
| required AllSchemas allSchemas, |
| required AllTestData allTestData, |
| required Uri packageUri, |
| String dataSuffix = '', |
| required FieldsFunction fields, |
| }) { |
| for (final hook in Hook.values) { |
| for (final party in Party.values) { |
| for (final inputOrOutput in InputOrOutput.values) { |
| final fields_ = fields( |
| hook: hook, |
| inputOrOutput: inputOrOutput, |
| party: party, |
| ); |
| if (fields_.isEmpty) { |
| continue; |
| } |
| |
| final schemaName = '${hook.name}_${inputOrOutput.name}'; |
| final schemaUri = packageUri.resolve( |
| 'doc/schema/${party.name}/$schemaName.generated.schema.json', |
| ); |
| final schema = allSchemas[schemaUri]!; |
| final dataName = '${hook.name}_${inputOrOutput.name}$dataSuffix'; |
| final dataUri = packageUri.resolve('test/data/$dataName.json'); |
| final data = allTestData[dataUri]!; |
| |
| for (final (field, missingExpectations) in fields_) { |
| testField( |
| field: field, |
| schemaUri: schemaUri, |
| schema: schema, |
| dataUri: dataUri, |
| data: data, |
| missingExpectations: missingExpectations, |
| ); |
| } |
| } |
| } |
| } |
| } |
| |
| /// Test all base hook expectations against the hook schemas. |
| /// |
| /// This has been put in a reusable location, to be able to run it on protocol |
| /// extensions. |
| void testFieldsHook({ |
| required AllSchemas allSchemas, |
| required AllTestData allTestData, |
| required Uri packageUri, |
| String dataSuffix = '', |
| }) { |
| testFields( |
| allSchemas: allSchemas, |
| allTestData: allTestData, |
| packageUri: packageUri, |
| dataSuffix: dataSuffix, |
| fields: _hookFields, |
| ); |
| } |
| |
| FieldsReturn _hookFields({ |
| required InputOrOutput inputOrOutput, |
| required Hook hook, |
| required Party party, |
| }) => <(List<Object>, void Function(ValidationResults result))>[ |
| ([r'$schema'], expectOptionalFieldMissing), |
| if (inputOrOutput == InputOrOutput.input) ...[ |
| (['user_defines'], expectOptionalFieldMissing), |
| (['user_defines', 'workspace_pubspec'], expectOptionalFieldMissing), |
| ( |
| ['user_defines', 'workspace_pubspec', 'base_path'], |
| expectRequiredFieldMissing, |
| ), |
| ( |
| ['user_defines', 'workspace_pubspec', 'defines'], |
| expectRequiredFieldMissing, |
| ), |
| (['out_dir_shared'], expectRequiredFieldMissing), |
| (['package_name'], expectRequiredFieldMissing), |
| (['package_root'], expectRequiredFieldMissing), |
| (['config', 'build_asset_types'], expectRequiredFieldMissing), |
| if (hook == Hook.build) ...[ |
| (['config', 'linking_enabled'], expectRequiredFieldMissing), |
| ], |
| if (hook == Hook.link) ...[ |
| (['assets'], expectOptionalFieldMissing), |
| (['assets', 0], expectOptionalFieldMissing), |
| (['assets', 0, 'type'], expectRequiredFieldMissing), |
| ], |
| (['out_file'], expectRequiredFieldMissing), |
| ], |
| if (inputOrOutput == InputOrOutput.output) ...[ |
| (['timestamp'], expectRequiredFieldMissing), |
| (['dependencies'], expectOptionalFieldMissing), |
| (['dependencies', 0], expectOptionalFieldMissing), |
| if (hook == Hook.build) ...[ |
| for (final path in [ |
| ['assets_for_build'], |
| ['assets_for_linking', 'package_with_linker'], |
| ]) ...[ |
| ([...path], expectOptionalFieldMissing), |
| ([...path, 0], expectOptionalFieldMissing), |
| ([...path, 0, 'type'], expectRequiredFieldMissing), |
| ([...path, 0, 'encoding'], expectOptionalFieldMissing), |
| ], |
| ], |
| ], |
| for (final path in [ |
| if (inputOrOutput == InputOrOutput.output || hook == Hook.link) ['assets'], |
| if (inputOrOutput == InputOrOutput.output && hook == Hook.build) ...[ |
| ['assets_for_build'], |
| ['assets_for_linking', 'package_with_linker'], |
| ], |
| ]) ...[ |
| ([...path], expectOptionalFieldMissing), |
| ([...path, 0], expectOptionalFieldMissing), |
| ([...path, 0, 'type'], expectRequiredFieldMissing), |
| ([...path, 0, 'encoding'], expectOptionalFieldMissing), |
| ], |
| ]; |
| |
| dynamic _traverseJson(dynamic json, List<Object> path) { |
| while (path.isNotEmpty) { |
| final key = path.removeAt(0); |
| switch (key) { |
| case final int i: |
| json = (json as List)[i] as Object; |
| break; |
| case final String s: |
| json = (json as Map)[s] as Object; |
| break; |
| default: |
| throw UnsupportedError(key.toString()); |
| } |
| } |
| return json; |
| } |