UNPKG

gpii-windows

Version:

Components of the GPII personalization infrastructure for use on Microsoft's "Windows" ™

1,530 lines (1,379 loc) 48.8 kB
/* * eventLog Tests * * Copyright 2017 Raising the Floor - International * * Licensed under the New BSD license. You may not use this file except in * compliance with this License. * * The R&D leading to these results received funding from the * Department of Education - Grant H421A150005 (GPII-APCP). However, * these results do not necessarily represent the policy of the * Department of Education, and you should not assume endorsement by the * Federal Government. * * You may obtain a copy of the License at * https://github.com/GPII/universal/blob/master/LICENSE.txt */ "use strict"; var fluid = require("gpii-universal"), fs = require("fs"), os = require("os"), net = require("net"), json5 = require("json5"), readline = require("readline"); require("../../processHandling/processHandling.js"); var jqUnit = fluid.require("node-jqunit"); var gpii = fluid.registerNamespace("gpii"); fluid.registerNamespace("gpii.tests.metrics"); require("../index.js"); var teardowns = []; jqUnit.module("gpii.tests.metrics", { teardown: function () { while (teardowns.length) { teardowns.pop()(); } } }); // Wrap the gpii.windowsMetrics to disable auto-starting the metrics capturing. fluid.defaults("gpii.tests.metrics.windowsMetricsWrapper", { gradeNames: ["fluid.component", "gpii.windowsMetrics", "gpii.eventLog", "gpii.lifecycleManager"], listeners: { "onStartMetrics.application": null, "onStartMetrics.input": null, "onStopMetrics.application": null, "onStopMetrics.input": null, "onCreate": { funcName: "gpii.tests.metrics.captureLog", args: ["{that}"] } }, invokers: { "startMessages": "fluid.identity" } }); gpii.tests.metrics.defaultKeyboardConfig = fluid.freezeRecursive(); gpii.tests.metrics.configMessageTests = fluid.freezeRecursive([ { // msg, wParam, lParam input: [gpii.windows.API_constants.WM_SYSCOLORCHANGE, 1, 2], expect: { module: "metrics", event: "config", data: { "wp": 1, "lp": 2, "msg": "WM_SYSCOLORCHANGE" } } }, { input: [gpii.windows.API_constants.WM_INPUTLANGCHANGE, 3, 4], expect: { module: "metrics", event: "config", data: { "wp": 3, "lp": 4, "msg": "WM_INPUTLANGCHANGE" } } }, { input: [gpii.windows.API_constants.WM_THEMECHANGED, 5, 6], expect: { module: "metrics", event: "config", data: { "wp": 5, "lp": 6, "msg": "WM_THEMECHANGED" } } }, { input: [gpii.windows.API_constants.WM_DISPLAYCHANGE, 32, gpii.windows.makeLong(1, 2)], expect: { module: "metrics", event: "config.resolution", data: { "wp": 32, "lp": 131073, "msg": "WM_DISPLAYCHANGE", width: 1, height: 2 } } }, { input: [gpii.windows.API_constants.WM_DISPLAYCHANGE, 16, gpii.windows.makeLong(3, 4)], expect: { module: "metrics", event: "config.resolution", data: { "wp": 16, "lp": 262147, "msg": "WM_DISPLAYCHANGE", width: 3, height: 4, bpp: 16 } } }, { // wParam=0 is not from spi input: [gpii.windows.API_constants.WM_SETTINGCHANGE, 0, 123], expect: { module: "metrics", event: "config", data: { "wp": 0, "lp": 123, "msg": "WM_SETTINGCHANGE" } } }, { input: [gpii.windows.API_constants.WM_SETTINGCHANGE, gpii.windows.spi.actions.SPI_SETSTICKYKEYS, 2], expect: { module: "metrics", event: "config.spi", data: { "wp": 59, "lp": 2, "msg": "WM_SETTINGCHANGE", "action": "SPI_SETSTICKYKEYS" } } }, { // unknown spi uiAction input: [gpii.windows.API_constants.WM_SETTINGCHANGE, 0x4000, 3], expect: { module: "metrics", event: "config", data: { "wp": 0x4000, "lp": 3, "msg": "WM_SETTINGCHANGE" } } } ]); // Tests for recordKeyTiming: key presses gpii.tests.metrics.recordKeyTimingKeyTests = fluid.freezeRecursive({ defaultState: { times: [] }, defaultConfig: { minSession: 0xffffff, sessionTimeout: 0xffffff, maxRecords: 1000 }, testData: [ { // Normal key press state: {}, input: { timestamp: 1, key: undefined }, expect: { module: "metrics", event: "key-time", data: {keyTime: 0} } }, { // special key state: null, input: { key: "BACK" }, expect: { module: "metrics", event: "key-time", data: {keyTime: 0, key: "BACK"} } }, // Test that non-special keys don't get leaked. { // not a special key (letter) state: null, input: { key: "A" }, expect: { module: "metrics", event: "key-time", data: {keyTime: 0, key: fluid.NO_VALUE } } }, { // not a special key (symbol) state: null, input: { key: "!" }, expect: { module: "metrics", event: "key-time", data: {keyTime: 0, key: fluid.NO_VALUE} } }, { // not a special key (numeric string) state: null, input: { key: "1" }, expect: { module: "metrics", event: "key-time", data: {keyTime: 0, key: fluid.NO_VALUE} } }, { // not a special key (number) state: null, input: { key: 0x41 }, expect: { module: "metrics", event: "key-time", data: {keyTime: 0, key: fluid.NO_VALUE} } }, { // not a special key (string) state: null, input: { key: "ABC" }, expect: { module: "metrics", event: "key-time", data: {keyTime: 0, key: fluid.NO_VALUE} } }, { // not a special key (virtual key code of a special key) state: null, input: { key: gpii.windows.API_constants.virtualKeyCodes.VK_ESCAPE }, expect: { module: "metrics", event: "key-time", data: {keyTime: 0, key: fluid.NO_VALUE} } } ] }); // Tests for recordKeyTiming: typing sessions gpii.tests.metrics.typingSessionTests = fluid.freezeRecursive({ defaultState: { times: [] }, defaultConfig: { minSession: 30000, sessionTimeout: 60000, minSessionKeys: 3 }, testData: [ { // Single session (3 keys, 1 minute) state: {}, input: [ { timestamp: 1 }, { timestamp: 20000 }, { timestamp: 60001 }, { timestamp: 200000 } ], expect: [{ module: "metrics", event: "typing-session", data: { duration: 60000, count: 3, corrections: 0, rate: 3 } }] }, { // Single session (10 keys, 5 minutes) state: {}, input: [ { timestamp: 1 }, { timestamp: 60000 }, { timestamp: 90000 }, { timestamp: 120000 }, { timestamp: 150000 }, { timestamp: 180000 }, { timestamp: 210000 }, { timestamp: 240000 }, { timestamp: 270000 }, { timestamp: 300001 }, { timestamp: 9000000 } ], expect: [{ module: "metrics", event: "typing-session", data: { duration: 300000, count: 10, corrections: 0, rate: 2 } }] }, { // Two sessions state: {}, input: [ // 1st { timestamp: 1 }, { timestamp: 20000 }, { timestamp: 60001 }, // 2nd { timestamp: 200000 }, { timestamp: 250000 }, { timestamp: 300000 }, { timestamp: 350000 }, { timestamp: 9000000 } ], expect: [{ module: "metrics", event: "typing-session", data: { duration: 60000, count: 3, corrections: 0, rate: 3 } }, { module: "metrics", event: "typing-session", data: { duration: 150000, count: 4, corrections: 0, rate: 2 } }] } ] }); // Tests for inputHook. gpii.tests.metrics.inputHookTests = fluid.freezeRecursive([ { // Invalid nCode. input: { nCode: -1, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: "A".charCodeAt(), scanCode: 0, flags: 0, time: 1, dwExtraInfo: 0 } }, expect: [] }, { // First key. input: { nCode: 0, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: "A".charCodeAt(), scanCode: 0, flags: 0, time: 150, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "key-time", data: { keyTime: 0, key: fluid.NO_VALUE } } }, { // Next key (50ms later). input: { nCode: 0, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: "A".charCodeAt(), scanCode: 0, flags: 0, time: 200, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "key-time", data: { keyTime: 50, key: fluid.NO_VALUE } } }, { // Non-special non-input key. input: { nCode: 0, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: gpii.windows.API_constants.virtualKeyCodes.VK_HOME, scanCode: 0, flags: 0, time: 200, dwExtraInfo: 0 } }, expect: [] }, { // Special key: back space. input: { nCode: 0, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: gpii.windows.API_constants.virtualKeyCodes.VK_BACK, scanCode: 0, flags: 0, time: 200, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "key-time", data: { keyTime: 0, key: "BACK" } } }, { // Special key: delete. input: { nCode: 0, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: gpii.windows.API_constants.virtualKeyCodes.VK_DELETE, scanCode: 0, flags: 0, time: 200, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "key-time", data: { keyTime: 0, key: "DELETE" } } }, { // Special key: escape. input: { nCode: 0, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: gpii.windows.API_constants.virtualKeyCodes.VK_ESCAPE, scanCode: 0, flags: 0, time: 200, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "key-time", data: { keyTime: 0, key: "ESCAPE" } } }, { // Normal key: 'X' input: { nCode: 0, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: "X".charCodeAt(), scanCode: 0, flags: 0, time: 200, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "key-time", data: { keyTime: 0, key: fluid.NO_VALUE } } }, { // Special key: left arrow. input: { nCode: 0, wParam: gpii.windows.API_constants.WM_KEYUP, lParam: { vkCode: gpii.windows.API_constants.virtualKeyCodes.VK_LEFT, scanCode: 0, flags: 0, time: 200, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "key-time", data: { keyTime: 0, key: "LEFT" } } }, { // "system key" (WM_SYSKEYUP is sent when alt is down, should behave the same as WM_KEYUP) input: { nCode: 0, wParam: gpii.windows.API_constants.WM_SYSKEYUP, lParam: { vkCode: gpii.windows.API_constants.virtualKeyCodes.VK_HOME, scanCode: 0, flags: 0, time: 200, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "key-time", data: { keyTime: 0, key: "HOME" } } }, // Mouse events { // Click input: { nCode: 0, wParam: gpii.windows.API_constants.WM_LBUTTONUP, lParam: { ptX: 0, ptY: 0, mouseData: 0, flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", data: { button: 1, distance: 0 } } }, { // Right click input: { nCode: 0, wParam: gpii.windows.API_constants.WM_RBUTTONUP, lParam: { ptX: 0, ptY: 0, mouseData: 0, flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", data: { button: 2, distance: 0 } } }, { // Click with movement input: { nCode: 0, wParam: gpii.windows.API_constants.WM_LBUTTONUP, lParam: { ptX: 100, ptY: 200, mouseData: 0, flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", data: { button: 1, distance: 224 } } }, { // Click with movement again - distance should be from the last coordinate. input: { nCode: 0, wParam: gpii.windows.API_constants.WM_RBUTTONUP, lParam: { ptX: 100 + 50, ptY: 200 + 60, mouseData: 0, flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", // sqrt(50^2 + 60^2) = 78 data: { button: 2, distance: 78 } } }, { // Just movement input: { nCode: 0, wParam: gpii.windows.API_constants.WM_MOUSEMOVE, lParam: { ptX: 150 - 25, ptY: 260 - 30, mouseData: 0, flags: 0, time: 0, dwExtraInfo: 0 } }, // Shouldn't produce anything expect: [] }, { // Click + more movement input: { nCode: 0, wParam: gpii.windows.API_constants.WM_LBUTTONUP, lParam: { ptX: 125 + 10, ptY: 230 + 20, mouseData: 0, flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", // (-25,-30) + (10,20) => 39 + 22 = 61 data: { button: 1, distance: 61 } } }, { // Mouse wheel up input: { nCode: 0, wParam: gpii.windows.API_constants.WM_MOUSEWHEEL, lParam: { ptX: 1, ptY: 2, mouseData: gpii.windows.makeLong(0, -120), flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", data: { wheel: -1 } } }, { // Mouse wheel down input: { nCode: 0, wParam: gpii.windows.API_constants.WM_MOUSEWHEEL, lParam: { ptX: 1, ptY: 2, mouseData: gpii.windows.makeLong(0, 120), flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", data: { wheel: 1 } } }, { // Mouse wheel up multiple input: { nCode: 0, wParam: gpii.windows.API_constants.WM_MOUSEWHEEL, lParam: { ptX: 1, ptY: 2, mouseData: gpii.windows.makeLong(0, -120 * 5), flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", data: { wheel: -5 } } }, { // Mouse wheel down multiple input: { nCode: 0, wParam: gpii.windows.API_constants.WM_MOUSEWHEEL, lParam: { ptX: 1, ptY: 2, mouseData: gpii.windows.makeLong(0, 120 * 3), flags: 0, time: 0, dwExtraInfo: 0 } }, expect: { module: "metrics", event: "mouse", data: { wheel: 3 } } } ]); gpii.tests.metrics.allLogLines = []; /** * Start a log server, to capture everything that's being logged in this test module. The logs will then be looked at * in the final test. * @param {Number} port The TCP port to listen on. * @return {net.Server} The TCP server. */ gpii.tests.metrics.createLogServer = function (port) { var buffer = ""; var logServer = net.createServer(); logServer.listen(port, "127.0.0.1"); logServer.on("listening", function () { fluid.log("[test] log server listening"); }); logServer.on("connection", function (socket) { fluid.log("[test] log server connection"); socket.setEncoding("utf8"); socket.on("data", function (chunk) { buffer += chunk; if (buffer.indexOf("\n") >= 0) { var lines = buffer.split("\n"); buffer = lines.pop(); lines.forEach(function (line) { gpii.tests.metrics.allLogLines.push(line); }); } }); }); logServer.on("close", function () { }); logServer.on("error", function (err) { jqUnit.fail(err); }); return logServer; }; gpii.tests.metrics.captureLog = function (that) { that.logHost = "127.0.0.1"; that.logPort = gpii.tests.metrics.logPort; }; gpii.tests.metrics.logPort = 50000 + Math.floor(Math.random() * 5000); gpii.tests.metrics.logServer = gpii.tests.metrics.createLogServer(gpii.tests.metrics.logPort); /** * Configure a log file that's just used for testing. * The file will be deleted automatically after the current test completes. * * @return {String} The path of the log file. */ gpii.tests.metrics.setLogFile = function () { var previousLogFile = gpii.eventLog.logFilePath; var logFile = os.tmpdir() + "/gpii-test-metrics-" + Date.now(); teardowns.push(function () { gpii.eventLog.logFilePath = previousLogFile; try { fs.unlinkSync(logFile); } catch (e) { // Ignored. } }); gpii.eventLog.logFilePath = logFile; return logFile; }; /** * Check if all properties of expected are also in subject and are equal, ignoring any extra ones in subject. * * A property's value in the expected object can be the expected value, fluid.VALUE to match any value (just check if * the property exists), or fluid.NO_VALUE to check the property doesn't exist. * * @param {Object} subject The object to check against * @param {Object} expected The object containing the values to check for. * @param {Number} maxDepth [Optional] How deep to check. * @return {Boolean} true if subject matches expected. */ gpii.tests.metrics.deepMatch = function (subject, expected, maxDepth) { var match = false; if (maxDepth < 0) { return false; } else if (!maxDepth && maxDepth !== 0) { maxDepth = fluid.strategyRecursionBailout; } if (!subject) { return subject === expected; } for (var prop in expected) { if (expected.hasOwnProperty(prop)) { var exp = expected[prop]; if (exp instanceof RegExp) { match = exp.test(subject[prop]); } else if (fluid.isMarker(exp, fluid.VALUE)) { // match any value match = subject.hasOwnProperty(prop); } else if (fluid.isMarker(exp, fluid.NO_VALUE)) { // match no value match = !subject.hasOwnProperty(prop); } else if (fluid.isPrimitive(exp)) { match = subject[prop] === exp; } else { match = gpii.tests.metrics.deepMatch(subject[prop], exp, maxDepth - 1); } if (!match) { break; } } } return match; }; /** * Checks the log file for some lines that are expected. (see deepMatch for the matching rules) * * @param {String} logFile The file to read. * @param {Array<Object>} expected An array of log items to search for (in order of how they should be found). * @return {Promise} Resolves when all expected lines where found, or rejects if the end of the log is reached first. */ gpii.tests.metrics.expectLogLines = function (logFile, expected) { jqUnit.expect(expected.length); var promise = fluid.promise(); var reader = readline.createInterface({ input: fs.createReadStream(logFile) }); var remainingLines = []; var index = 0; var currentExpected = expected[index]; var complete = false; reader.input.on("error", function (err) { jqUnit.fail(err); }); reader.on("line", function (line) { if (complete) { return; } fluid.log(line); var obj = JSON.parse(line); remainingLines.push(obj); if (gpii.tests.metrics.deepMatch(obj, currentExpected)) { jqUnit.assert("Found a matching line"); index++; remainingLines = []; if (index >= expected.length) { complete = true; reader.close(); } else { currentExpected = expected[index]; } } }); reader.on("close", function () { reader.input.close(); if (complete) { promise.resolve(); } else { fluid.log("No matching line (index=" + index + "):", currentExpected); fluid.log("Remaining log lines:", remainingLines); promise.reject("Unable to find matching log entry " + index); } }); return promise; }; /** * Completes a test of logging, by checking if the given log file contains the expected lines. * * @param {String} logFile The log file. * @param {Array<Object>} expectedLines An array of log items to search for (in order of how they should be found). */ gpii.tests.metrics.completeLogTest = function (logFile, expectedLines) { gpii.tests.metrics.expectLogLines(logFile, expectedLines).then(function () { jqUnit.assert("All expected lines should be found."); jqUnit.start(); }, function (err) { jqUnit.fail(err); }); }; jqUnit.test("Testing path generification", function () { var env = { "SystemRoot": "C:\\Windows", "APPDATA": "C:\\Users\\vagrant\\AppData\\Roaming", "TEMP": "C:\\Users\\vagrant\\AppData\\Local\\Temp", "LOCALAPPDATA": "C:\\Users\\vagrant\\AppData\\Local", "HOME": "C:\\Users\\vagrant", "USERPROFILE": "C:\\Users\\vagrant" }; var tests = { "c:\\xyz\\abc": "c:\\xyz\\abc", "c:\\windows\\notepad.exe": "%SystemRoot%\\notepad.exe", "c:\\WINDOWS\\NOTEPAD1.EXE": "%SystemRoot%\\NOTEPAD1.EXE", "c:/windows/notepad2.exe": "%SystemRoot%\\notepad2.exe", "c:\\users\\vagrant\\file": "%HOME%\\file", "c:\\users\\vagrant\\\\\\file2": "%HOME%\\file2", "c:\\users\\moo\\..\\vagrant\\file3": "%HOME%\\file3", "c:\\windows\\..\\users\\hello\\..\\vagrant\\file3": "%HOME%\\file3", "c:\\users\\vagrant": "%HOME%", "c:\\users\\vagrant\\": "%HOME%\\", "c:\\users\\vagrant\\\\": "%HOME%\\", "C:\\Users\\vagrant\\AppData\\Roaming": "%APPDATA%", "C:\\Users\\vagrant\\AppData\\Roaming\\file": "%APPDATA%\\file", "C:\\Users\\vagrant\\AppData\\Roaming\\..\\file": "%HOME%\\AppData\\file", "C:\\Users\\vagrant\\AppData\\Roaming\\..\\Local\\file": "%LOCALAPPDATA%\\file" }; fluid.each(tests, function (expect, test) { var result = gpii.windows.metrics.genericisePath(test, env); jqUnit.assertEquals("Path " + test + " should match the expected value", expect, result); }); }); /** * Test the application metrics - app launches and window times. */ jqUnit.asyncTest("Testing application metrics", function () { jqUnit.expect(1); var logFile = gpii.tests.metrics.setLogFile(); var windowsMetrics = gpii.tests.metrics.windowsMetricsWrapper({ members: { logFilePath: logFile } }); // Pick three windows (owned by different processes) that already exist, and pretend they've became active. var windows = []; var exes = {}; gpii.windows.enumerateWindows(function (hwnd) { if (windows.length < 3) { var exe = gpii.windows.getProcessPath(gpii.windows.getWindowProcessId(hwnd)); if (!exes[exe] && !/ApplicationFrameHost/i.test(exe)) { exes[exe] = true; windows.push({ exe: gpii.windows.metrics.genericisePath(exe), hwnd: hwnd }); } } }); // Mock gpii.windows.isProcessRunning so it looks like the processes are not running when the window is closed. var oldIsProcessRunning = gpii.windows.isProcessRunning; gpii.windows.isProcessRunning = function () { return false; }; teardowns.push(function () { gpii.windows.isProcessRunning = oldIsProcessRunning; }); var activeWindowIndex = 0; var expectedLines = []; // Activate the windows a few times. var activateWindow = function (remaining) { activeWindowIndex = (activeWindowIndex + 1) % windows.length; // Pretend the "window has been activated" notification has been received. A fuller test would have sent the // actual message. However, there isn't a real message loop when running under node (rather than electron). gpii.windows.metrics.windowMessage(windowsMetrics, 0, gpii.windows.API_constants.WM_SHELLHOOK, gpii.windows.API_constants.HSHELL_WINDOWACTIVATED, windows[activeWindowIndex].hwnd); if (remaining > 0) { if (!windows[activeWindowIndex].done) { // The launch event is only expected on a window's first occurrence. expectedLines.push({ module: "metrics", event: "app-launch", data: { exe: windows[activeWindowIndex].exe } }); windows[activeWindowIndex].done = true; } expectedLines.push({ module: "metrics", event: "app-active", data: { exe: windows[activeWindowIndex].exe, window: fluid.VALUE } }); process.nextTick(activateWindow, remaining - 1); } else { // Destroy a window gpii.windows.metrics.windowMessage(windowsMetrics, 0, gpii.windows.API_constants.WM_SHELLHOOK, gpii.windows.API_constants.HSHELL_WINDOWDESTROYED, windows[0].hwnd); expectedLines.push({ module: "metrics", event: "app-close", data: { exe: windows[0].exe, windowClass: fluid.VALUE } }); setTimeout(function () { windowsMetrics.stopApplicationMetrics(); gpii.tests.metrics.completeLogTest(logFile, expectedLines); windowsMetrics.destroy(); }, 100); } }; // Start monitoring. windowsMetrics.startApplicationMetrics(); activateWindow(windows.length * 2); }); jqUnit.asyncTest("Testing system configuration notifications", function () { jqUnit.expect(2); var logFile = gpii.tests.metrics.setLogFile(); var windowsMetrics = gpii.tests.metrics.windowsMetricsWrapper({ members: { logFilePath: logFile } }); var untestedMessages = fluid.arrayToHash(gpii.windows.metrics.settingsMessages); var expectedLines = []; fluid.each(gpii.tests.metrics.configMessageTests, function (test) { var msg = test.input[0], wp = test.input[1], lp = test.input[2]; gpii.windows.metrics.windowMessage(windowsMetrics, 0, msg, wp, lp); if (untestedMessages[msg]) { delete untestedMessages[msg]; } expectedLines.push(test.expect); }); jqUnit.assertDeepEq("All messages in gpii.windows.metrics.settingsMessages should be tested", {}, untestedMessages); gpii.tests.metrics.completeLogTest(logFile, expectedLines); }); /** * Test recordKeyTiming * @param {Object} theTests The tests; gpii.tests.metrics.recordKeyTimingKeyTests or typingSessionTests. */ gpii.tests.metrics.recordKeyTimingTests = function (theTests) { jqUnit.expect(1); var logFile = gpii.tests.metrics.setLogFile(); var testData = theTests.testData; var defaultState = theTests.defaultState; var defaultConfig = theTests.defaultConfig; var windowsMetrics = gpii.tests.metrics.windowsMetricsWrapper({ members: { logFilePath: logFile } }); var expectedLines = []; fluid.each(testData, function (test) { var state = fluid.copy(test.state || defaultState); state.config = fluid.copy(state.config || defaultConfig); windowsMetrics.state.input = state; windowsMetrics.config.input = state.config; // "press" the keys fluid.each(fluid.makeArray(test.input), function (input) { gpii.windows.metrics.recordKeyTiming(windowsMetrics, input.timestamp, input.key); }); expectedLines.push.apply(expectedLines, fluid.makeArray(test.expect)); }); gpii.tests.metrics.completeLogTest(logFile, expectedLines); }; jqUnit.asyncTest("Testing keyboard metrics: recordKeyTiming (key presses)", function () { gpii.tests.metrics.recordKeyTimingTests(gpii.tests.metrics.recordKeyTimingKeyTests); }); jqUnit.asyncTest("Testing keyboard metrics: recordKeyTiming (typing sessions)", function () { gpii.tests.metrics.recordKeyTimingTests(gpii.tests.metrics.typingSessionTests); }); // This also tests recordMouseEvent (indirectly) jqUnit.asyncTest("Testing input metrics: inputHook", function () { jqUnit.expect(1); var logFile = gpii.tests.metrics.setLogFile(); var windowsMetrics = gpii.tests.metrics.windowsMetricsWrapper({ members: { logFilePath: logFile } }); // Get the initial state, so it can be reset (otherwise tests will depend on the previous tests, making it tricky // to change) var initialState = fluid.copy(windowsMetrics.state.input); // Disable typing sessions windowsMetrics.config.input.minSession = windowsMetrics.config.input.sessionTimeout = -1 >>> 0; windowsMetrics.config.input.maxRecords = 1; windowsMetrics.startInputMetrics(); var testData = gpii.tests.metrics.inputHookTests; var expectedLines = []; var currentTest = null; var modifiers = [ {name: "SHIFT", code: gpii.windows.API_constants.virtualKeyCodes.VK_SHIFT}, {name: "CONTROL", code: gpii.windows.API_constants.virtualKeyCodes.VK_CONTROL}, {name: "ALT", code: gpii.windows.API_constants.virtualKeyCodes.VK_MENU} ]; var expectedModifiers = []; // Tests a single element of the test data. var testInput = function () { fluid.each(testData, function (test) { currentTest = test.input; gpii.windows.metrics.inputHook(windowsMetrics, currentTest.nCode, currentTest.wParam, currentTest.lParam); // insert the modifierKeys field to the expect result, if necessary. var expected = fluid.transform(fluid.makeArray(test.expect), function (expect) { var togo = fluid.copy(expect); if (expectedModifiers.length > 0) { togo.data.modifierKeys = expectedModifiers; } return togo; }); expectedLines.push.apply(expectedLines, expected); }); }; // Mock windows.getModifierKeys to return the test data (it's better than simulating the key state) var getModifierKeysOrig = gpii.windows.metrics.getModifierKeys; teardowns.push(function () { gpii.windows.metrics.getModifierKeys = getModifierKeysOrig; }); gpii.windows.metrics.getModifierKeys = function () { return expectedModifiers; }; // Run all of the input tests, with different combinations of modifier keys pressed. for (var pass = 0; pass < Math.pow(2, modifiers.length); pass++) { expectedModifiers = []; for (var n = 0; n < modifiers.length; n++) { var set = (pass >> n) & 1; if (set) { expectedModifiers.push(modifiers[n].name); } } windowsMetrics.startInputMetrics(); windowsMetrics.state.input = fluid.copy(initialState); testInput(expectedModifiers); } // The events are put in the log in the next tick (to allow the hook handler to return quickly). setImmediate(function () { windowsMetrics.stopInputMetrics(); gpii.tests.metrics.completeLogTest(logFile, expectedLines); }); }); // Ensure no character keys are whitelisted for being logged. jqUnit.test("Testing input metrics: inputHook - not logging character keys", function () { var keyCodes = Object.keys(gpii.windows.metrics.specialKeys); jqUnit.expect(keyCodes.length + 2); // Return true if keyCode corresponds to a character key. var isCharacterKey = function (keyCode) { var char = gpii.windows.user32.MapVirtualKeyW(keyCode, gpii.windows.API_constants.MAPVK_VK_TO_CHAR); return char && char >= 0x20; }; isCharacterKey(gpii.windows.API_constants.virtualKeyCodes.VK_BACK); // Sanity check. var keyA = 0x41; jqUnit.assertTrue("'A' key should be a character key", isCharacterKey(keyA)); jqUnit.assertFalse("'HOME' key should not be a character key", isCharacterKey(gpii.windows.API_constants.virtualKeyCodes.VK_HOME)); fluid.each(keyCodes, function (key) { jqUnit.assertFalse("Keys in windows.metrics.specialKeys must all be non-printable: " + gpii.windows.metrics.specialKeys[key], isCharacterKey(key)); }); }); // Ensure only the value of whitelisted keys are being logged. jqUnit.asyncTest("Testing input metrics: inputHook - only logging desired keys", function () { var logFile = gpii.tests.metrics.setLogFile(); var windowsMetrics = gpii.tests.metrics.windowsMetricsWrapper({ members: { logFilePath: logFile } }); // Disable typing sessions windowsMetrics.config.input.minSession = windowsMetrics.config.input.sessionTimeout = -1 >>> 0; windowsMetrics.config.input.maxRecords = 1; windowsMetrics.startInputMetrics(); var lParam = { flags: 0, time: 0, dwExtraInfo: 0 }; var keyCount = 0; // Send every key. for (var keyCode = 0; keyCode <= 0xff; keyCode++) { lParam.vkCode = keyCode; gpii.windows.metrics.inputHook(windowsMetrics, 0, gpii.windows.API_constants.WM_KEYUP, lParam); keyCount++; } // Log a bad key, to test the test. windowsMetrics.logMetric("key-time", { keyTime: 0, key: "A", // Used to mark this log entry as a test value. selfTest: "this is a test" }); keyCount++; var expectedCount = 257; jqUnit.assertEquals("self-test: there should be 257 key presses, including the bad one.", expectedCount, keyCount); // The events are put in the log in the next tick (to allow the hook handler to return quickly). setImmediate(function () { windowsMetrics.stopInputMetrics(); var gotSelfTest = false; var loggedKeys = {}; var loggedSpecialKeys = []; var logLines = fs.readFileSync(logFile).toString().trim().split(/[\n\r]+/); fluid.each(logLines, function (line) { var logEntry = JSON.parse(line); if (logEntry.event === "key-time") { jqUnit.assertNotNull("Key event must have a data field", logEntry.data); var key = logEntry.data.key; if (key) { jqUnit.assertEquals("value of data.key must be a string", "string", typeof key); jqUnit.assertTrue("value of data.key must not be a number", isNaN(parseInt(key))); // Failire means there's something wrong with the logging or this test. jqUnit.assertFalse("Keys should only be logged once.", !!loggedKeys[key]); loggedKeys[key] = true; // Make sure the key is supposed to be logged. var keyCode = fluid.keyForValue(gpii.windows.metrics.specialKeys, key); var found = keyCode !== undefined; if (found) { // Double check for the alpha-numeric keys /* from WinUser.h: * VK_0 - VK_9 are the same as ASCII '0' - '9' (0x30 - 0x39) * 0x3A - 0x40 : unassigned * VK_A - VK_Z are the same as ASCII 'A' - 'Z' (0x41 - 0x5A) */ var isAlphaNum = (keyCode >= 0x30 && keyCode <= 0x5a); // This means a number/letter key is in the whitelist. jqUnit.assertFalse("Alphanumeric keys should not be logged: " + key, isAlphaNum); loggedSpecialKeys.push(key); } else { // This key should not be logged. if (logEntry.data.selfTest === "this is a test" && !gotSelfTest) { // The self-test key gotSelfTest = true; jqUnit.assert("Got self-test key."); } else { jqUnit.fail("An undesired key was logged"); } } } } }); // Make sure the test ran fully. var allKeys = fluid.values(gpii.windows.metrics.specialKeys); jqUnit.assertDeepEq("All special keys should have been logged", allKeys, loggedSpecialKeys); jqUnit.assertTrue("The self-test bad key should have been found", gotSelfTest); jqUnit.start(); }); }); jqUnit.asyncTest("Testing input metrics: inactivity", function () { jqUnit.expect(4); var logFile = gpii.tests.metrics.setLogFile(); var windowsMetrics = gpii.tests.metrics.windowsMetricsWrapper({ members: { logFilePath: logFile } }); // Make the inactivity timeout very quick windowsMetrics.config.input.inactiveTime = 1; windowsMetrics.startInputMetrics(); var state = windowsMetrics.state.input; jqUnit.assertFalse("Should not be inactive at start", state.inactive); gpii.windows.metrics.userInput(windowsMetrics); jqUnit.assertFalse("Should not be inactive after first input", state.inactive); setTimeout(function () { jqUnit.assertTrue("Should be inactive after timer", state.inactive); gpii.windows.metrics.userInput(windowsMetrics); jqUnit.assertFalse("Should not be inactive after last input", state.inactive); windowsMetrics.stopInputMetrics(); jqUnit.start(); }, 10); }); jqUnit.asyncTest("Testing version and system info logging", function () { jqUnit.expect(1); var logFile = gpii.tests.metrics.setLogFile(); var windowsMetrics = gpii.tests.metrics.windowsMetricsWrapper({ members: { logFilePath: logFile } }); windowsMetrics.events.onStartMetrics.fire(); gpii.tests.metrics.completeLogTest(logFile, [ { module: "metrics", event: "version", data: { "windowsMetrics": fluid.VALUE, "gpii-app": fluid.VALUE, "gpii-windows": fluid.VALUE, "gpii-universal": fluid.VALUE } }, { module: "metrics", event: "system-info", data: { "cpu": fluid.VALUE, "cores": /^[1-9][0-9]?$/, "memory": /^[0-9]+(\.[0-9]+)?$/, "resolution": /^[0-9]+x[0-9]+$/, "scale": /^[0-9]+(\.[0-9]+)?$/, "osBits": /^(32|64)$/, "osRelease": fluid.VALUE, "osEdition": /Windows/, "systemMfr": fluid.VALUE, "systemName": fluid.VALUE } } ]); }); /** * Checks the documentation (README.md) by extracting the JSON code regions for the following: * - Every type of event that is logged, from this test module, is in the document. * - The all data fields for each event are listed in the document. * - All documented fields have been used. * * This will not expose event data that is both untested and undocumented. */ jqUnit.test("Testing metrics documentation", function () { gpii.tests.metrics.logServer.close(); var docPath = __dirname + "/../README.md"; // ignored events (implemented in gpii-universal) var ignore = { "gpii": ["stop"] }; // Parse the documentation for json blocks var content = fs.readFileSync(docPath, "utf8"); var reg = /```json5?(((?!```)[\s\S])*)```/g; var docObjects = []; var match; while ((match = reg.exec(content))) { var json = match[1]; var obj; try { obj = json5.parse(json); } catch (e) { fluid.log("Unable to parse this: ", json); jqUnit.fail(e); throw e; } // The values in the data object do not matter (just the existence) obj.data = fluid.transform(obj.data, function () { return ""; }); docObjects.push(obj); } var documentedEvents = {}; // Gather the keys of the data object for each documented event. The same event may be documented more than once, in // that case the keys for each one are combined. fluid.each(docObjects, function (obj) { var eventData = fluid.get(documentedEvents, [obj.module, obj.event]); var dataKeys = obj.data ? Object.keys(obj.data) : []; var keyHash = fluid.arrayToHash(dataKeys); if (eventData) { Object.assign(eventData.keys, keyHash); eventData.documentation = fluid.makeArray(eventData.documentation); eventData.documentation.push(obj); } else { eventData = { keys: keyHash, documentation: obj }; fluid.set(documentedEvents, [obj.module, obj.event], eventData); } eventData.remainingKeys = fluid.copy(eventData.keys); }); // Hack for the selfTest key used in testing of the key timing metrics. fluid.set(documentedEvents, "metrics.key-time.keys.selfTest", true); // Check every log entry matches one of the objects in the documentation. fluid.each(gpii.tests.metrics.allLogLines, function (logLine) { var logObject = JSON.parse(logLine); if (!ignore[logObject.module] || !ignore[logObject.module].includes(logObject.event)) { var eventData = fluid.get(documentedEvents, [logObject.module, logObject.event]); if (eventData) { // Check each field in the data object is documented. var dataKeys = Object.keys(logObject.data || {}); fluid.each(dataKeys, function (key) { var isDocumented = eventData.keys[key]; if (!isDocumented) { fluid.log("Log entry:", logObject); fluid.log("Documented object:", eventData.documentation); } jqUnit.assertTrue("All data fields should be documented. field: " + key, isDocumented); }); // Cross off the documented keys that have been seen. eventData.remainingKeys = fluid.filterKeys(eventData.remainingKeys, dataKeys, true); } else { fluid.log("Undocumented event:", logObject); } jqUnit.assertTrue( "Event '" + logObject.module + ":" + logObject.event + "' should be documented.", !!eventData); } }); // Check if documented fields are not being sent. Could be a sign of outdated documentation, or poor testing. fluid.each(documentedEvents, function (module, moduleName) { fluid.each(module, function (event, eventName) { var unused = Object.keys(event.remainingKeys); if (unused.length > 0) { fluid.log("Stale documented fields: ", unused); } jqUnit.assertTrue("All documented fields should be used. " + moduleName + ":" + eventName, unused.length === 0); }); }); jqUnit.assert(); });