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 +}