From a5c338d7e11cdc925c8467e234ee419ab3cfdcda Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 21 Nov 2017 23:44:10 +0100 Subject: [PATCH] Add support for generating android matchers This restructures the generation to allow for multiple adapters, leaving this open for further extension. It only adds one generated action to the live code, so that the diff doesn't get any bigger --- detox/package.json | 5 +- detox/src/android/espressoapi/DetoxAction.js | 142 +++++++++ detox/src/android/expect.js | 3 +- .../__tests__/__snapshots__/android.js.snap | 19 ++ .../__tests__/__snapshots__/earl-grey.js.snap | 100 ------ .../__tests__/__snapshots__/ios.js.snap | 100 ++++++ generation/__tests__/android.js | 54 ++++ generation/__tests__/global-functions.js | 2 +- generation/__tests__/{earl-grey.js => ios.js} | 16 +- generation/adapters/android.js | 25 ++ generation/adapters/ios.js | 67 ++++ generation/core/generator.js | 205 ++++++++++++ .../{earl-grey => core}/global-functions.js | 0 generation/core/type-checks.js | 49 +++ generation/earl-grey/index.js | 293 ------------------ generation/fixtures/example.java | 229 ++++++++++++++ generation/helpers.js | 10 +- generation/index.js | 12 +- generation/package.json | 5 +- 19 files changed, 925 insertions(+), 411 deletions(-) create mode 100644 detox/src/android/espressoapi/DetoxAction.js create mode 100644 generation/__tests__/__snapshots__/android.js.snap delete mode 100644 generation/__tests__/__snapshots__/earl-grey.js.snap create mode 100644 generation/__tests__/__snapshots__/ios.js.snap create mode 100644 generation/__tests__/android.js rename generation/__tests__/{earl-grey.js => ios.js} (95%) create mode 100644 generation/adapters/android.js create mode 100644 generation/adapters/ios.js create mode 100644 generation/core/generator.js rename generation/{earl-grey => core}/global-functions.js (100%) create mode 100644 generation/core/type-checks.js delete mode 100644 generation/earl-grey/index.js create mode 100644 generation/fixtures/example.java diff --git a/detox/package.json b/detox/package.json index bdf7642c24..299383c4eb 100644 --- a/detox/package.json +++ b/detox/package.json @@ -71,11 +71,12 @@ "Emulator.js", "DeviceDriverBase.js", "GREYConfiguration.js", - "src/ios/earlgreyapi", "src/utils/environment.js", "AAPT.js", "ADB.js", - "fsext.js" + "fsext.js", + "src/ios/earlgreyapi", + "src/android/espressoapi" ], "resetMocks": true, "resetModules": true, diff --git a/detox/src/android/espressoapi/DetoxAction.js b/detox/src/android/espressoapi/DetoxAction.js new file mode 100644 index 0000000000..bb4e346f0b --- /dev/null +++ b/detox/src/android/espressoapi/DetoxAction.js @@ -0,0 +1,142 @@ +/** + + This code is generated. + For more information see generation/README.md. +*/ + + +// Globally declared helpers + +function sanitize_greyDirection(action) { + switch (action) { + case "left": + return 1; + case "right": + return 2; + case "up": + return 3; + case "down": + return 4; + + default: + throw new Error(`GREYAction.GREYDirection must be a 'left'/'right'/'up'/'down', got ${action}`); + } +} + +function sanitize_greyContentEdge(action) { + switch (action) { + case "left": + return 0; + case "right": + return 1; + case "top": + return 2; + case "bottom": + return 3; + + default: + throw new Error(`GREYAction.GREYContentEdge must be a 'left'/'right'/'top'/'bottom', got ${action}`); + } +} + +function sanitize_uiAccessibilityTraits(value) { + let traits = 0; + for (let i = 0; i < value.length; i++) { + switch (value[i]) { + case 'button': traits |= 1; break; + case 'link': traits |= 2; break; + case 'header': traits |= 4; break; + case 'search': traits |= 8; break; + case 'image': traits |= 16; break; + case 'selected': traits |= 32; break; + case 'plays': traits |= 64; break; + case 'key': traits |= 128; break; + case 'text': traits |= 256; break; + case 'summary': traits |= 512; break; + case 'disabled': traits |= 1024; break; + case 'frequentUpdates': traits |= 2048; break; + case 'startsMedia': traits |= 4096; break; + case 'adjustable': traits |= 8192; break; + case 'allowsDirectInteraction': traits |= 16384; break; + case 'pageTurn': traits |= 32768; break; + default: throw new Error(`Unknown trait '${value[i]}', see list in https://facebook.github.io/react-native/docs/accessibility.html#accessibilitytraits-ios`); + } + } + + return traits; +} + + + +class DetoxAction { + static multiClick(times) { + if (typeof times !== "number") throw new Error("times should be a number, but got " + (times + (" (" + (typeof times + ")")))); + return { + target: { + type: "Class", + value: "com.wix.detox.espresso.DetoxAction" + }, + method: "multiClick", + args: [{ + type: "Integer", + value: times + }] + }; + } + + static tapAtLocation(x, y) { + if (typeof x !== "number") throw new Error("x should be a number, but got " + (x + (" (" + (typeof x + ")")))); + if (typeof y !== "number") throw new Error("y should be a number, but got " + (y + (" (" + (typeof y + ")")))); + return { + target: { + type: "Class", + value: "com.wix.detox.espresso.DetoxAction" + }, + method: "tapAtLocation", + args: [{ + type: "Integer", + value: x + }, { + type: "Integer", + value: y + }] + }; + } + + static scrollToEdge(edge) { + if (typeof edge !== "number") throw new Error("edge should be a number, but got " + (edge + (" (" + (typeof edge + ")")))); + return { + target: { + type: "Class", + value: "com.wix.detox.espresso.DetoxAction" + }, + method: "scrollToEdge", + args: [{ + type: "Integer", + value: edge + }] + }; + } + + static scrollInDirection(direction, amountInDP) { + if (typeof direction !== "number") throw new Error("direction should be a number, but got " + (direction + (" (" + (typeof direction + ")")))); + if (typeof amountInDP !== "number") throw new Error("amountInDP should be a number, but got " + (amountInDP + (" (" + (typeof amountInDP + ")")))); + return { + target: { + type: "Class", + value: "com.wix.detox.espresso.DetoxAction" + }, + method: "scrollInDirection", + args: [{ + type: "Integer", + value: direction + }, { + type: "double", + value: amountInDP + }] + }; + } + +} + +module.exports = DetoxAction; \ No newline at end of file diff --git a/detox/src/android/expect.js b/detox/src/android/expect.js index 80e80cf875..ea48800864 100644 --- a/detox/src/android/expect.js +++ b/detox/src/android/expect.js @@ -1,5 +1,6 @@ const invoke = require('../invoke'); const matchers = require('./matcher'); +const DetoxActionApi = require('./espressoapi/DetoxAction'); const Matcher = matchers.Matcher; const LabelMatcher = matchers.LabelMatcher; const IdMatcher = matchers.IdMatcher; @@ -51,7 +52,7 @@ class LongPressAction extends Action { class MultiClickAction extends Action { constructor(times) { super(); - this._call = invoke.call(invoke.Android.Class(DetoxAction), 'multiClick', invoke.Android.Integer(times)); + this._call = invoke.callDirectly(DetoxActionApi.multiClick(times)); } } diff --git a/generation/__tests__/__snapshots__/android.js.snap b/generation/__tests__/__snapshots__/android.js.snap new file mode 100644 index 0000000000..5107f02726 --- /dev/null +++ b/generation/__tests__/__snapshots__/android.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Android generation methods should generate type checks 1`] = `"times should be a number, but got FOO (string)"`; + +exports[`Android generation methods should return adapter calls 1`] = ` +Object { + "args": Array [ + Object { + "type": "Integer", + "value": 3, + }, + ], + "method": "multiClick", + "target": Object { + "type": "Class", + "value": "com.wix.detox.espresso.DetoxAction", + }, +} +`; diff --git a/generation/__tests__/__snapshots__/earl-grey.js.snap b/generation/__tests__/__snapshots__/earl-grey.js.snap deleted file mode 100644 index b5f323fff1..0000000000 --- a/generation/__tests__/__snapshots__/earl-grey.js.snap +++ /dev/null @@ -1,100 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`earl-grey generation Error handling should thow error for CGPoint with wrong x and y values 1`] = `"point.x should be a number, but got 3 (string)"`; - -exports[`earl-grey generation Error handling should thow error for CGPoint with wrong x and y values 2`] = `"point.y should be a number, but got undefined (undefined)"`; - -exports[`earl-grey generation Error handling should throw error for not in accepted range 1`] = `"direction should be one of [left, right, up, down], but got flipside"`; - -exports[`earl-grey generation Error handling should throw error for wrong type 1`] = `"count should be a number, but got foo (string)"`; - -exports[`earl-grey generation Error handling should throw error for wrong type 2`] = `"point should be a object, but got 4 (number)"`; - -exports[`earl-grey generation Invocations should return the invocation object for methods 1`] = ` -Object { - "args": Array [ - Object { - "type": "NSInteger", - "value": 3, - }, - ], - "method": "actionForMultipleTapsWithCount:", - "target": Object { - "type": "Class", - "value": "GREYActions", - }, -} -`; - -exports[`earl-grey generation Invocations should return the invocation object for methods with objects as args 1`] = ` -Object { - "args": Array [ - Object { - "type": "NSInteger", - "value": 3, - }, - Object { - "type": "CGPoint", - "value": Object { - "x": 3, - "y": 4, - }, - }, - ], - "method": "actionForMultipleTapsWithCount:atPoint:", - "target": Object { - "type": "Class", - "value": "GREYActions", - }, -} -`; - -exports[`earl-grey generation Invocations should return the invocation object for methods with strings 1`] = ` -Object { - "args": Array [ - Object { - "type": "NSString", - "value": "Foo", - }, - ], - "method": "actionForTypeText:", - "target": Object { - "type": "Class", - "value": "GREYActions", - }, -} -`; - -exports[`earl-grey generation Invocations should sanitize the directions 1`] = ` -Object { - "args": Array [ - Object { - "type": "NSInteger", - "value": 4, - }, - Object { - "type": "CGFloat", - "value": 3, - }, - Object { - "type": "CGFloat", - "value": 4, - }, - Object { - "type": "CGFloat", - "value": 5, - }, - ], - "method": "actionForScrollInDirection:amount:xOriginStartPercentage:yOriginStartPercentage:", - "target": Object { - "type": "Class", - "value": "GREYActions", - }, -} -`; - -exports[`earl-grey generation special case: id should fail with wrongly formatted matchers 1`] = `"firstMatcher should be a GREYMatcher, but got {\\"type\\":\\"Invocation\\",\\"value\\":{\\"target\\":{\\"type\\":\\"Class\\",\\"value\\":\\"GREYAction\\"},\\"method\\":\\"matcherForAccessibilityID:\\",\\"args\\":[\\"Grandfather883\\"]}}"`; - -exports[`earl-grey generation special case: id should fail with wrongly formatted matchers 2`] = `"ancestorMatcher should be a GREYMatcher, but got {\\"type\\":\\"Invocation\\",\\"value\\":{\\"target\\":{\\"type\\":\\"Class\\",\\"value\\":\\"GREYAction\\"},\\"method\\":\\"matcherForAccessibilityID:\\",\\"args\\":[\\"Grandson883\\"]}}"`; - -exports[`earl-grey generation special case: id should fail with wrongly formatted matchers 3`] = `"firstMatcher should be a GREYMatcher, but got {\\"type\\":\\"Invocation\\",\\"value\\":{\\"method\\":\\"matcherForAccessibilityID:\\",\\"args\\":[\\"Grandfather883\\"]}}"`; diff --git a/generation/__tests__/__snapshots__/ios.js.snap b/generation/__tests__/__snapshots__/ios.js.snap new file mode 100644 index 0000000000..c5887dd054 --- /dev/null +++ b/generation/__tests__/__snapshots__/ios.js.snap @@ -0,0 +1,100 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`iOS generation Error handling should thow error for CGPoint with wrong x and y values 1`] = `"point.x should be a number, but got 3 (string)"`; + +exports[`iOS generation Error handling should thow error for CGPoint with wrong x and y values 2`] = `"point.y should be a number, but got undefined (undefined)"`; + +exports[`iOS generation Error handling should throw error for not in accepted range 1`] = `"direction should be one of [left, right, up, down], but got flipside"`; + +exports[`iOS generation Error handling should throw error for wrong type 1`] = `"count should be a number, but got foo (string)"`; + +exports[`iOS generation Error handling should throw error for wrong type 2`] = `"point should be a object, but got 4 (number)"`; + +exports[`iOS generation Invocations should return the invocation object for methods 1`] = ` +Object { + "args": Array [ + Object { + "type": "NSInteger", + "value": 3, + }, + ], + "method": "actionForMultipleTapsWithCount:", + "target": Object { + "type": "Class", + "value": "GREYActions", + }, +} +`; + +exports[`iOS generation Invocations should return the invocation object for methods with objects as args 1`] = ` +Object { + "args": Array [ + Object { + "type": "NSInteger", + "value": 3, + }, + Object { + "type": "CGPoint", + "value": Object { + "x": 3, + "y": 4, + }, + }, + ], + "method": "actionForMultipleTapsWithCount:atPoint:", + "target": Object { + "type": "Class", + "value": "GREYActions", + }, +} +`; + +exports[`iOS generation Invocations should return the invocation object for methods with strings 1`] = ` +Object { + "args": Array [ + Object { + "type": "NSString", + "value": "Foo", + }, + ], + "method": "actionForTypeText:", + "target": Object { + "type": "Class", + "value": "GREYActions", + }, +} +`; + +exports[`iOS generation Invocations should sanitize the directions 1`] = ` +Object { + "args": Array [ + Object { + "type": "NSInteger", + "value": 4, + }, + Object { + "type": "CGFloat", + "value": 3, + }, + Object { + "type": "CGFloat", + "value": 4, + }, + Object { + "type": "CGFloat", + "value": 5, + }, + ], + "method": "actionForScrollInDirection:amount:xOriginStartPercentage:yOriginStartPercentage:", + "target": Object { + "type": "Class", + "value": "GREYActions", + }, +} +`; + +exports[`iOS generation special case: id should fail with wrongly formatted matchers 1`] = `"firstMatcher should be a GREYMatcher, but got {\\"type\\":\\"Invocation\\",\\"value\\":{\\"target\\":{\\"type\\":\\"Class\\",\\"value\\":\\"GREYAction\\"},\\"method\\":\\"matcherForAccessibilityID:\\",\\"args\\":[\\"Grandfather883\\"]}}"`; + +exports[`iOS generation special case: id should fail with wrongly formatted matchers 2`] = `"ancestorMatcher should be a GREYMatcher, but got {\\"type\\":\\"Invocation\\",\\"value\\":{\\"target\\":{\\"type\\":\\"Class\\",\\"value\\":\\"GREYAction\\"},\\"method\\":\\"matcherForAccessibilityID:\\",\\"args\\":[\\"Grandson883\\"]}}"`; + +exports[`iOS generation special case: id should fail with wrongly formatted matchers 3`] = `"firstMatcher should be a GREYMatcher, but got {\\"type\\":\\"Invocation\\",\\"value\\":{\\"method\\":\\"matcherForAccessibilityID:\\",\\"args\\":[\\"Grandfather883\\"]}}"`; diff --git a/generation/__tests__/android.js b/generation/__tests__/android.js new file mode 100644 index 0000000000..26886b6e28 --- /dev/null +++ b/generation/__tests__/android.js @@ -0,0 +1,54 @@ +const fs = require("fs"); +const remove = require("remove"); +const androidGenerator = require("../adapters/android"); + +describe("Android generation", () => { + let ExampleClass; + let exampleContent; + beforeAll(() => { + // Generate the code to test + fs.mkdirSync("./__tests__/generated-android"); + + const files = { + "./fixtures/example.java": "./__tests__/generated-android/example.js" + }; + + console.log('==> generating android files'); + androidGenerator(files); + + console.log('==> loading android files'); + // Load + ExampleClass = require("./generated-android/example.js"); + exampleContent = fs.readFileSync( + "./__tests__/generated-android/example.js", + "utf8" + ); + }); + + afterAll(() => { + // Clean up + remove.removeSync("./__tests__/generated-android"); + }); + + describe("methods", () => { + it("should expose the functions", () => { + expect(ExampleClass.multiClick).toBeInstanceOf(Function); + }); + + it("should generate type checks", () => { + expect(() => { + ExampleClass.multiClick("FOO"); + }).toThrowErrorMatchingSnapshot(); + }); + + it("should return adapter calls", () => { + const result = ExampleClass.multiClick(3); + expect(result.method).toBe('multiClick'); + expect(result.target.value).toBe('com.wix.detox.espresso.DetoxAction'); + expect(result.args[0].type).toBe('Integer'); + expect(result.args[0].value).toBe(3); + + expect(result).toMatchSnapshot(); + }); + }); +}); \ No newline at end of file diff --git a/generation/__tests__/global-functions.js b/generation/__tests__/global-functions.js index 71e49527f0..83cc235b7b 100644 --- a/generation/__tests__/global-functions.js +++ b/generation/__tests__/global-functions.js @@ -1,4 +1,4 @@ -const globals = require("../earl-grey/global-functions"); +const globals = require("../core/global-functions"); describe("globals", () => { describe("sanitize_greyDirection", () => { diff --git a/generation/__tests__/earl-grey.js b/generation/__tests__/ios.js similarity index 95% rename from generation/__tests__/earl-grey.js rename to generation/__tests__/ios.js index 50f11fdbfe..6dc86d87a1 100644 --- a/generation/__tests__/earl-grey.js +++ b/generation/__tests__/ios.js @@ -1,26 +1,26 @@ const fs = require("fs"); const remove = require("remove"); -const earlGreyGenerator = require("../earl-grey"); +const iosGenerator = require("../adapters/ios"); -describe("earl-grey generation", () => { +describe("iOS generation", () => { let ExampleClass; let exampleContent; beforeAll(() => { // Generate the code to test - fs.mkdirSync("./__tests__/generated"); + fs.mkdirSync("./__tests__/generated-ios"); const files = { - "./fixtures/example.h": "./__tests__/generated/example.js" + "./fixtures/example.h": "./__tests__/generated-ios/example.js" }; console.log('==> generating earl grey files'); - earlGreyGenerator(files); + iosGenerator(files); console.log('==> loading earl grey files'); // Load - ExampleClass = require("./generated/example.js"); + ExampleClass = require("./generated-ios/example.js"); exampleContent = fs.readFileSync( - "./__tests__/generated/example.js", + "./__tests__/generated-ios/example.js", "utf8" ); }); @@ -324,6 +324,6 @@ describe("earl-grey generation", () => { afterAll(() => { // Clean up - remove.removeSync("./__tests__/generated"); + remove.removeSync("./__tests__/generated-ios"); }); }); diff --git a/generation/adapters/android.js b/generation/adapters/android.js new file mode 100644 index 0000000000..26b6961809 --- /dev/null +++ b/generation/adapters/android.js @@ -0,0 +1,25 @@ +const t = require("babel-types"); +const generator = require("../core/generator"); + +const { + isNumber +} = require('../core/type-checks'); + +const typeCheckInterfaces = { + Integer: isNumber, + double: isNumber +}; + +module.exports = generator({ + typeCheckInterfaces, + supportedContentSanitizersMap: {}, + supportedTypes: [ + 'Integer', + 'int', + 'double', + ], + renameTypesMap: { + "int": "Integer" // TODO: add test + }, + classValue: ({ package: pkg, name }) => `${pkg}.${name}` +}); diff --git a/generation/adapters/ios.js b/generation/adapters/ios.js new file mode 100644 index 0000000000..bdf2aa8377 --- /dev/null +++ b/generation/adapters/ios.js @@ -0,0 +1,67 @@ +const t = require("babel-types"); +const generator = require("../core/generator"); +const { + isNumber, + isString, + isBoolean, + isPoint, + isOneOf, + isGreyMatcher, + isArray +} = require('../core/type-checks'); +const {callGlobal} = require('../helpers'); + +const typeCheckInterfaces = { + NSInteger: isNumber, + CGFloat: isNumber, + CGPoint: isPoint, + CFTimeInterval: isNumber, + double: isNumber, + float: isNumber, + NSString: isString, + BOOL: isBoolean, + "NSDate *": isNumber, + GREYDirection: isOneOf(["left", "right", "up", "down"]), + GREYContentEdge: isOneOf(["left", "right", "top", "bottom"]), + GREYPinchDirection: isOneOf(["outward", "inward"]), + "id": isGreyMatcher, + UIAccessibilityTraits: isArray, +}; + +const supportedContentSanitizersMap = { + GREYDirection: { + type: "NSInteger", + value: callGlobal("sanitize_greyDirection") + }, + GREYContentEdge: { + type: "NSInteger", + value: callGlobal("sanitize_greyContentEdge") + }, + UIAccessibilityTraits: { + type: "NSInteger", + value: callGlobal("sanitize_uiAccessibilityTraits") + } +}; + +module.exports = generator({ + typeCheckInterfaces, + supportedContentSanitizersMap, + supportedTypes: [ + "CGFloat", + "CGPoint", + "GREYContentEdge", + "GREYDirection", + "NSInteger", + "NSString *", + "NSString", + "NSUInteger", + "id", + "UIAccessibilityTraits" + ], + renameTypesMap: { + NSUInteger: "NSInteger", + "NSString *": "NSString" + }, + classValue: ({ name }) => name, + +}); \ No newline at end of file diff --git a/generation/core/generator.js b/generation/core/generator.js new file mode 100644 index 0000000000..83188b0178 --- /dev/null +++ b/generation/core/generator.js @@ -0,0 +1,205 @@ +const t = require("babel-types"); +const template = require("babel-template"); +const objectiveCParser = require("objective-c-parser"); +const javaMethodParser = require("java-method-parser"); +const generate = require("babel-generator").default; +const fs = require("fs"); + +const { methodNameToSnakeCase } = require("../helpers"); + +module.exports = function ({ typeCheckInterfaces, renameTypesMap, supportedTypes, classValue, supportedContentSanitizersMap }) { + /** + * the input provided by objective-c-parser looks like this: + * { + * "name": "BasicName", + * "methods": [ + * { + * "args": [], + * "comment": "This is the comment of basic method one", + * "name": "basicMethodOne", + * "returnType": "NSInteger" + * }, + * { + * "args": [ + * { + * "type": "NSInteger", + * "name": "argOne" + * }, + * { + * "type": "NSString", + * "name": "argTwo" + * } + * ], + * "comment": "This is the comment of basic method two.\nIt has multiple lines", + * "name": "basicMethodTwoWithArgOneAndArgTwo", + * "returnType": "NSString" + * } + * ] + * } + */ + function createClass(json) { + return t.classDeclaration( + t.identifier(json.name), + null, + t.classBody(json.methods.filter(filterMethodsWithUnsupportedParams).map(createMethod.bind(null, json))), + [] + ); + } + + function filterMethodsWithUnsupportedParams(method) { + return method.args.reduce((carry, methodArg) => carry && supportedTypes.includes(methodArg.type), true); + } + + function createExport(json) { + return t.expressionStatement( + t.assignmentExpression( + "=", + t.memberExpression( + t.identifier("module"), + t.identifier("exports"), + false + ), + t.identifier(json.name) + ) + ); + } + + function createMethod(classJson, json) { + const m = t.classMethod( + "method", + t.identifier(methodNameToSnakeCase(json.name)), + json.args.map(({ name }) => t.identifier(name)), + t.blockStatement(createMethodBody(classJson, json)), + false, + json.static + ); + + if (json.comment) { + const comment = { + type: json.comment.indexOf("\n") === -1 ? "LineComment" : "BlockComment", + value: json.comment + "\n" + }; + + m.leadingComments = m.leadingComments || []; + m.leadingComments.push(comment); + } + return m; + } + + function sanitizeArgumentType(json) { + if (renameTypesMap[json.type]) { + return Object.assign({}, json, { + type: renameTypesMap[json.type] + }); + } + return json; + } + + function createMethodBody(classJson, json) { + const sanitizedJson = Object.assign({}, json, { + args: json.args.map(argJson => sanitizeArgumentType(argJson)) + }); + + const allTypeChecks = createTypeChecks(sanitizedJson).reduce( + (carry, item) => + item instanceof Array ? [...carry, ...item] : [...carry, item], + [] + ); + const typeChecks = allTypeChecks.filter(check => typeof check === "object"); + const returnStatement = createReturnStatement(classJson, sanitizedJson); + return [...typeChecks, returnStatement]; + } + + function createTypeChecks(json) { + const checks = json.args.map(createTypeCheck); + checks.filter(check => Boolean(check)); + return checks; + } + + function addArgumentContentSanitizerCall(json) { + if (supportedContentSanitizersMap[json.type]) { + return supportedContentSanitizersMap[json.type].value(json.name); + } + + return t.identifier(json.name); + } + function addArgumentTypeSanitizer(json) { + if (supportedContentSanitizersMap[json.type]) { + return supportedContentSanitizersMap[json.type].type; + } + + return json.type; + } + + // These types need no wrapping with {type: ..., value: } + const plainArgumentTypes = ["id"]; + function shouldBeWrapped({ type }) { + return !plainArgumentTypes.includes(type); + } + function createReturnStatement(classJson, json) { + const args = json.args.map(arg => shouldBeWrapped(arg) ? + t.objectExpression([ + t.objectProperty( + t.identifier("type"), + t.stringLiteral(addArgumentTypeSanitizer(arg)) + ), + t.objectProperty( + t.identifier("value"), + addArgumentContentSanitizerCall(arg) + ) + ]) : addArgumentContentSanitizerCall(arg) + ); + + return t.returnStatement( + t.objectExpression([ + t.objectProperty( + t.identifier("target"), + t.objectExpression([ + t.objectProperty(t.identifier("type"), t.stringLiteral("Class")), + t.objectProperty(t.identifier("value"), t.stringLiteral(classValue(classJson))) + ]) + ), + t.objectProperty(t.identifier("method"), t.stringLiteral(json.name)), + t.objectProperty(t.identifier("args"), t.arrayExpression(args)) + ]) + ); + } + + function createTypeCheck(json) { + const typeCheckCreator = typeCheckInterfaces[json.type]; + const isListOfChecks = typeCheckCreator instanceof Array; + return isListOfChecks + ? typeCheckCreator.map(singleCheck => singleCheck(json)) + : typeCheckCreator(json); + } + + return function (files) { + Object.entries(files).forEach(([inputFile, outputFile]) => { + const input = fs.readFileSync(inputFile, "utf8"); + const isObjectiveC = inputFile[inputFile.length - 1] === 'h'; + + const json = isObjectiveC ? objectiveCParser(input) : javaMethodParser(input); + const ast = t.program([createClass(json), createExport(json)]); + const output = generate(ast); + + const commentBefore = "/**\n\n\tThis code is generated.\n\tFor more information see generation/README.md.\n*/\n\n"; + + // Add global helper functions + const globalFunctionsSource = fs.readFileSync(__dirname + "/global-functions.js", "utf8"); + const globalFunctions = globalFunctionsSource.substr(0, globalFunctionsSource.indexOf("module.exports")); + + const code = [commentBefore, globalFunctions, output.code].join('\n'); + fs.writeFileSync(outputFile, code, "utf8"); + + // Output methods that were not created due to missing argument support + const unsupportedMethods = json.methods.filter(x => !filterMethodsWithUnsupportedParams(x)); + if (unsupportedMethods.length) { + console.log(`Could not generate the following methods for ${json.name}`); + unsupportedMethods.forEach(method => { + const methodArgs = method.args.filter(methodArg => !supportedTypes.includes(methodArg.type)).map(methodArg => methodArg.type); + console.log(`\t ${method.name} misses ${methodArgs}`); + }); + } + }); + }; +}; diff --git a/generation/earl-grey/global-functions.js b/generation/core/global-functions.js similarity index 100% rename from generation/earl-grey/global-functions.js rename to generation/core/global-functions.js diff --git a/generation/core/type-checks.js b/generation/core/type-checks.js new file mode 100644 index 0000000000..b1262fcbce --- /dev/null +++ b/generation/core/type-checks.js @@ -0,0 +1,49 @@ +const t = require("babel-types"); +const template = require("babel-template"); +const { + generateTypeCheck, + generateIsOneOfCheck +} = require("babel-generate-guard-clauses"); + +const isNumber = generateTypeCheck("number"); +const isString = generateTypeCheck("string"); +const isBoolean = generateTypeCheck("boolean"); +const isPoint = [ + generateTypeCheck("object"), + generateTypeCheck("number", { selector: "x" }), + generateTypeCheck("number", { selector: "y" }) +]; +const isOneOf = generateIsOneOfCheck; +const isGreyMatcher = ({ name }) => template(` + if ( + typeof ARG !== "object" || + ARG.type !== "Invocation" || + typeof ARG.value !== "object" || + typeof ARG.value.target !== "object" || + ARG.value.target.value !== "GREYMatchers" + ) { + throw new Error('${name} should be a GREYMatcher, but got ' + JSON.stringify(ARG)); + } +`)({ + ARG: t.identifier(name) + }); +const isArray = ({ name }) => template(` +if ( + (typeof ARG !== 'object') || + (!ARG instanceof Array) +) { + throw new Error('TraitsMatcher ctor argument must be an array, got ' + typeof ARG); + } +`)({ + ARG: t.identifier(name) + }); + +module.exports = { + isNumber, + isString, + isBoolean, + isPoint, + isOneOf, + isGreyMatcher, + isArray +}; \ No newline at end of file diff --git a/generation/earl-grey/index.js b/generation/earl-grey/index.js deleted file mode 100644 index 8c55c71296..0000000000 --- a/generation/earl-grey/index.js +++ /dev/null @@ -1,293 +0,0 @@ -const t = require("babel-types"); -const template = require("babel-template"); -const objectiveCParser = require("objective-c-parser"); -const generate = require("babel-generator").default; -const fs = require("fs"); - -const { methodNameToSnakeCase } = require("../helpers"); - -const { - generateTypeCheck, - generateIsOneOfCheck -} = require("babel-generate-guard-clauses"); - -const isNumber = generateTypeCheck("number"); -const isString = generateTypeCheck("string"); -const isBoolean = generateTypeCheck("boolean"); -const isPoint = [ - generateTypeCheck("object"), - generateTypeCheck("number", { selector: "x" }), - generateTypeCheck("number", { selector: "y" }) -]; -const isOneOf = generateIsOneOfCheck; -const isGreyMatcher = ({ name }) => template(` - if ( - typeof ARG !== "object" || - ARG.type !== "Invocation" || - typeof ARG.value !== "object" || - typeof ARG.value.target !== "object" || - ARG.value.target.value !== "GREYMatchers" - ) { - throw new Error('${name} should be a GREYMatcher, but got ' + JSON.stringify(ARG)); - } -`)({ - ARG: t.identifier(name) - }); -const isArray = ({ name }) => template(` -if ( - (typeof ARG !== 'object') || - (!ARG instanceof Array) -) { - throw new Error('TraitsMatcher ctor argument must be an array, got ' + typeof ARG); - } -`)({ - ARG: t.identifier(name) - }); - - -// Constants -const SUPPORTED_TYPES = [ - "CGFloat", - "CGPoint", - "GREYContentEdge", - "GREYDirection", - "NSInteger", - "NSString *", - "NSString", - "NSUInteger", - "id", - "UIAccessibilityTraits" -]; - -/** - * the input provided by objective-c-parser looks like this: - * { - * "name": "BasicName", - * "methods": [ - * { - * "args": [], - * "comment": "This is the comment of basic method one", - * "name": "basicMethodOne", - * "returnType": "NSInteger" - * }, - * { - * "args": [ - * { - * "type": "NSInteger", - * "name": "argOne" - * }, - * { - * "type": "NSString", - * "name": "argTwo" - * } - * ], - * "comment": "This is the comment of basic method two.\nIt has multiple lines", - * "name": "basicMethodTwoWithArgOneAndArgTwo", - * "returnType": "NSString" - * } - * ] - * } - */ -function createClass(json) { - return t.classDeclaration( - t.identifier(json.name), - null, - t.classBody(json.methods.filter(filterMethodsWithUnsupportedParams).map(createMethod.bind(null, json.name))), - [] - ); -} - -function filterMethodsWithUnsupportedParams(method) { - return method.args.reduce((carry, methodArg) => carry && SUPPORTED_TYPES.includes(methodArg.type), true); -} - -function createExport(json) { - return t.expressionStatement( - t.assignmentExpression( - "=", - t.memberExpression( - t.identifier("module"), - t.identifier("exports"), - false - ), - t.identifier(json.name) - ) - ); -} - -function createMethod(className, json) { - const m = t.classMethod( - "method", - t.identifier(methodNameToSnakeCase(json.name)), - json.args.map(({ name }) => t.identifier(name)), - t.blockStatement(createMethodBody(className, json)), - false, - json.static - ); - - if (json.comment) { - const comment = { - type: json.comment.indexOf("\n") === -1 ? "LineComment" : "BlockComment", - value: json.comment + "\n" - }; - - m.leadingComments = m.leadingComments || []; - m.leadingComments.push(comment); - } - return m; -} - -const renameTypesMap = { - NSUInteger: "NSInteger", - "NSString *": "NSString" -}; - -function sanitizeArgumentType(json) { - if (renameTypesMap[json.type]) { - return Object.assign({}, json, { - type: renameTypesMap[json.type] - }); - } - return json; -} - -function createMethodBody(className, json) { - const sanitizedJson = Object.assign({}, json, { - args: json.args.map(argJson => sanitizeArgumentType(argJson)) - }); - - const allTypeChecks = createTypeChecks(sanitizedJson).reduce( - (carry, item) => - item instanceof Array ? [...carry, ...item] : [...carry, item], - [] - ); - const typeChecks = allTypeChecks.filter(check => typeof check === "object"); - const returnStatement = createReturnStatement(className, sanitizedJson); - return [...typeChecks, returnStatement]; -} - -function createTypeChecks(json) { - const checks = json.args.map(createTypeCheck); - checks.filter(check => Boolean(check)); - return checks; -} - -const callGlobal = sanitizerName => argIdentifier => - t.callExpression(t.identifier(sanitizerName), [t.identifier(argIdentifier)]); -const supportedContentSanitizersMap = { - GREYDirection: { - type: "NSInteger", - value: callGlobal("sanitize_greyDirection") - }, - GREYContentEdge: { - type: "NSInteger", - value: callGlobal("sanitize_greyContentEdge") - }, - UIAccessibilityTraits: { - type: "NSInteger", - value: callGlobal("sanitize_uiAccessibilityTraits") - } -}; -function addArgumentContentSanitizerCall(json) { - if (supportedContentSanitizersMap[json.type]) { - return supportedContentSanitizersMap[json.type].value(json.name); - } - - return t.identifier(json.name); -} -function addArgumentTypeSanitizer(json) { - if (supportedContentSanitizersMap[json.type]) { - return supportedContentSanitizersMap[json.type].type; - } - - return json.type; -} - -// These types need no wrapping with {type: ..., value: } -const plainArgumentTypes = ["id"]; -function shouldBeWrapped({ type }) { - return !plainArgumentTypes.includes(type); -} -function createReturnStatement(className, json) { - const args = json.args.map(arg => shouldBeWrapped(arg) ? - t.objectExpression([ - t.objectProperty( - t.identifier("type"), - t.stringLiteral(addArgumentTypeSanitizer(arg)) - ), - t.objectProperty( - t.identifier("value"), - addArgumentContentSanitizerCall(arg) - ) - ]) : addArgumentContentSanitizerCall(arg) - ); - - return t.returnStatement( - t.objectExpression([ - t.objectProperty( - t.identifier("target"), - t.objectExpression([ - t.objectProperty(t.identifier("type"), t.stringLiteral("Class")), - t.objectProperty(t.identifier("value"), t.stringLiteral(className)) - ]) - ), - t.objectProperty(t.identifier("method"), t.stringLiteral(json.name)), - t.objectProperty(t.identifier("args"), t.arrayExpression(args)) - ]) - ); -} - -function createTypeCheck(json) { - const typeInterfaces = { - NSInteger: isNumber, - CGFloat: isNumber, - CGPoint: isPoint, - CFTimeInterval: isNumber, - double: isNumber, - float: isNumber, - NSString: isString, - BOOL: isBoolean, - "NSDate *": isNumber, - GREYDirection: isOneOf(["left", "right", "up", "down"]), - GREYContentEdge: isOneOf(["left", "right", "top", "bottom"]), - GREYPinchDirection: isOneOf(["outward", "inward"]), - "id": isGreyMatcher, - UIAccessibilityTraits: isArray, - }; - - const typeCheckCreator = typeInterfaces[json.type]; - const isListOfChecks = typeCheckCreator instanceof Array; - - return isListOfChecks - ? typeCheckCreator.map(singleCheck => singleCheck(json)) - : typeCheckCreator(json); -} - -module.exports = function(files) { - Object.entries(files).forEach(([inputFile, outputFile]) => { - const input = fs.readFileSync(inputFile, "utf8"); - - const json = objectiveCParser(input); - const ast = t.program([createClass(json), createExport(json)]); - const output = generate(ast); - - const commentBefore = "/**\n\n\tThis code is generated.\n\tFor more information see generation/README.md.\n*/\n\n"; - - // Add global helper functions - const globalFunctionsSource = fs.readFileSync(__dirname + "/global-functions.js", "utf8"); - const globalFunctions = globalFunctionsSource.substr(0, globalFunctionsSource.indexOf("module.exports")); - - const code = [commentBefore, globalFunctions, output.code].join('\n'); - fs.writeFileSync(outputFile, code, "utf8"); - - // Output methods that were not created due to missing argument support - const unsupportedMethods = json.methods.filter(x => !filterMethodsWithUnsupportedParams(x)); - if (unsupportedMethods.length) { - console.log(`Could not generate the following methods for ${json.name}`); - unsupportedMethods.forEach(method => { - const methodArgs = method.args.filter(methodArg => !SUPPORTED_TYPES.includes(methodArg.type)).map(methodArg => methodArg.type); - console.log(`\t ${method.name} misses ${methodArgs}`); - }); - } - }); -}; diff --git a/generation/fixtures/example.java b/generation/fixtures/example.java new file mode 100644 index 0000000000..04ad13b187 --- /dev/null +++ b/generation/fixtures/example.java @@ -0,0 +1,229 @@ +package com.wix.detox.espresso; + +import android.support.test.espresso.UiController; +import android.support.test.espresso.ViewAction; +import android.support.test.espresso.action.CoordinatesProvider; +import android.support.test.espresso.action.GeneralClickAction; +import android.support.test.espresso.action.GeneralLocation; +import android.support.test.espresso.action.GeneralSwipeAction; +import android.support.test.espresso.action.Press; +import android.support.test.espresso.action.Swipe; +import android.support.test.espresso.action.Tap; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; +import android.widget.AbsListView; +import android.widget.HorizontalScrollView; +import android.widget.ScrollView; + +import org.hamcrest.Matcher; + +import static android.support.test.espresso.action.ViewActions.actionWithAssertions; +import static android.support.test.espresso.action.ViewActions.swipeDown; +import static android.support.test.espresso.action.ViewActions.swipeLeft; +import static android.support.test.espresso.action.ViewActions.swipeRight; +import static android.support.test.espresso.action.ViewActions.swipeUp; +import static android.support.test.espresso.matcher.ViewMatchers.isAssignableFrom; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static org.hamcrest.Matchers.allOf; + + +/** + * Created by simonracz on 10/07/2017. + */ + +public class DetoxAction { + private static final String LOG_TAG = "detox"; + + private DetoxAction() { + // static class + } + + public static ViewAction multiClick(int times) { + return actionWithAssertions(new GeneralClickAction(new MultiTap(times), GeneralLocation.CENTER, Press.FINGER, 0, 0)); + } + + public static ViewAction tapAtLocation(final int x, final int y) { + final int px = UiAutomatorHelper.convertDiptoPix(x); + final int py = UiAutomatorHelper.convertDiptoPix(y); + CoordinatesProvider c = new CoordinatesProvider() { + @Override + public float[] calculateCoordinates(View view) { + final int[] xy = new int[2]; + view.getLocationOnScreen(xy); + final float fx = xy[0] + px; + final float fy = xy[1] + py; + float[] coordinates = {fx, fy}; + return coordinates; + } + }; + return actionWithAssertions(new GeneralClickAction( + Tap.SINGLE, c, Press.FINGER, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY)); + } + + /** + * Scrolls to the edge of the given scrollable view. + * + * Edge + * 1 -> Left + * 2 -> Right + * 3 -> Top + * 4 -> Bottom + * + * @param edge + * @return ViewAction + */ + public static ViewAction scrollToEdge(final int edge) { + return actionWithAssertions(new ViewAction() { + @Override + public Matcher getConstraints() { + return allOf(isAssignableFrom(View.class), isDisplayed()); + } + + @Override + public String getDescription() { + return "scrollToEdge"; + } + + @Override + public void perform(UiController uiController, View view) { + Class recyclerViewClass = null; + try { + recyclerViewClass = Class.forName(RecyclerViewScrollListener.CLASS_RECYCLERVIEW); + } catch (ClassNotFoundException e) { + // ok + } + if (view instanceof AbsListView) { + RNScrollListener l = new RNScrollListener((AbsListView) view); + do { + ScrollHelper.performOnce(uiController, view, edge); + } while (l.didScroll()); + l.cleanup(); + } else if (view instanceof ScrollView) { + int prevScrollY = view.getScrollY(); + while (true) { + ScrollHelper.performOnce(uiController, view, edge); + int currentScrollY = view.getScrollY(); + if (currentScrollY == prevScrollY) break; + prevScrollY = currentScrollY; + } + } else if (view instanceof HorizontalScrollView) { + int prevScrollX = view.getScrollX(); + while (true) { + ScrollHelper.performOnce(uiController, view, edge); + int currentScrollX = view.getScrollX(); + if (currentScrollX == prevScrollX) break; + prevScrollX = currentScrollX; + } + } else if (recyclerViewClass != null && recyclerViewClass.isInstance(view)) { + RecyclerViewScrollListener l = new RecyclerViewScrollListener(view); + do { + ScrollHelper.performOnce(uiController, view, edge); + } while (l.didScroll()); + l.cleanup(); + } else { + throw new RuntimeException( + "Only descendants of AbsListView, ScrollView, HorizontalScrollView and RecyclerView are supported"); + } + } + }); + } + + /** + * Scrolls the View in a direction by the Density Independent Pixel amount. + * + * Direction + * 1 -> left + * 2 -> Right + * 3 -> Up + * 4 -> Down + * + * @param direction Direction to scroll + * @param amountInDP Density Independent Pixels + * + */ + public static ViewAction scrollInDirection(final int direction, final double amountInDP) { + return actionWithAssertions(new ViewAction() { + @Override + public Matcher getConstraints() { + return allOf(isAssignableFrom(View.class), isDisplayed()); + } + + @Override + public String getDescription() { + return "scrollInDirection"; + } + + @Override + public void perform(UiController uiController, View view) { + ScrollHelper.perform(uiController, view, direction, amountInDP); + } + }); + } + + private final static float EDGE_FUZZ_FACTOR = 0.083f; + + /** + * Swipes the View in a direction. + * + * Direction + * 1 -> left + * 2 -> Right + * 3 -> Up + * 4 -> Down + * + * @param direction Direction to scroll + * @param fast true if fast, false if slow + * + */ + public static ViewAction swipeInDirection(final int direction, boolean fast) { + if (fast) { + switch (direction) { + case 1: + return swipeLeft(); + case 2: + return swipeRight(); + case 3: + return swipeUp(); + case 4: + return swipeDown(); + default: + throw new RuntimeException("Unsupported swipe direction: " + direction); + } + } + switch (direction) { + case 1: + return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW, + translate(GeneralLocation.CENTER_RIGHT, -EDGE_FUZZ_FACTOR, 0), + GeneralLocation.CENTER_LEFT, Press.FINGER)); + case 2: + return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW, + translate(GeneralLocation.CENTER_LEFT, EDGE_FUZZ_FACTOR, 0), + GeneralLocation.CENTER_RIGHT, Press.FINGER)); + case 3: + return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW, + translate(GeneralLocation.BOTTOM_CENTER, 0, -EDGE_FUZZ_FACTOR), + GeneralLocation.TOP_CENTER, Press.FINGER)); + case 4: + return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW, + translate(GeneralLocation.TOP_CENTER, 0, EDGE_FUZZ_FACTOR), + GeneralLocation.BOTTOM_CENTER, Press.FINGER)); + default: + throw new RuntimeException("Unsupported swipe direction: " + direction); + } + } + + private static CoordinatesProvider translate(final CoordinatesProvider coords, + final float dx, final float dy) { + return new CoordinatesProvider() { + @Override + public float[] calculateCoordinates(View view) { + float xy[] = coords.calculateCoordinates(view); + xy[0] += dx * view.getWidth(); + xy[1] += dy * view.getHeight(); + return xy; + } + }; + } + +} \ No newline at end of file diff --git a/generation/helpers.js b/generation/helpers.js index d7f3262641..288647c8d9 100644 --- a/generation/helpers.js +++ b/generation/helpers.js @@ -1,3 +1,5 @@ +const t = require("babel-types"); + function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } @@ -10,6 +12,12 @@ function methodNameToSnakeCase(name) { ).join(''); } +function callGlobal(sanitizerName) { + return argIdentifier => + t.callExpression(t.identifier(sanitizerName), [t.identifier(argIdentifier)]); +} + module.exports = { - methodNameToSnakeCase, + methodNameToSnakeCase, + callGlobal }; \ No newline at end of file diff --git a/generation/index.js b/generation/index.js index 16c8c46a61..70e835b8b4 100755 --- a/generation/index.js +++ b/generation/index.js @@ -1,9 +1,15 @@ #!/usr/bin/env node -const generateEarlGreyAdapters = require("./earl-grey"); -const files = { +const generateIOSAdapters = require("./adapters/ios"); +const iosFiles = { "../detox/ios/EarlGrey/EarlGrey/Action/GREYActions.h": "../detox/src/ios/earlgreyapi/GREYActions.js", "../detox/ios/Detox/GREYMatchers+Detox.h": "../detox/src/ios/earlgreyapi/GREYMatchers+Detox.js", "../detox/ios/EarlGrey/EarlGrey/Matcher/GREYMatchers.h": "../detox/src/ios/earlgreyapi/GREYMatchers.js", }; -generateEarlGreyAdapters(files); +generateIOSAdapters(iosFiles); + +const generateAndroidAdapters = require("./adapters/android"); +const androidFiles = { + "../detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java": "../detox/src/android/espressoapi/DetoxAction.js" +}; +generateAndroidAdapters(androidFiles); \ No newline at end of file diff --git a/generation/package.json b/generation/package.json index 465365c106..4a2df37e53 100644 --- a/generation/package.json +++ b/generation/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "babel-generate-guard-clauses": "^2.0.0", - "babel-template": "^6.26.0" + "babel-template": "^6.26.0", + "java-method-parser": "^0.4.0" } -} \ No newline at end of file +}