| // Copyright (C) 2019, 2020 Apple Inc. All rights reserved. |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions |
| // are met: |
| // 1. Redistributions of source code must retain the above copyright |
| // notice, this list of conditions and the following disclaimer. |
| // 2. Redistributions in binary form must reproduce the above copyright |
| // notice, this list of conditions and the following disclaimer in the |
| // documentation and/or other materials provided with the distribution. |
| // |
| // THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" |
| // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, |
| // THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| // PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS |
| // BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| // CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| // SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| // CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF |
| // THE POSSIBILITY OF SUCH DAMAGE. |
| |
| import {ArchiveRouter} from '/assets/js/archiveRouter.js'; |
| import {CommitBank} from '/assets/js/commit.js'; |
| import {Configuration} from '/assets/js/configuration.js'; |
| import {deepCompare, ErrorDisplay, escapeHTML, paramsToQuery, queryToParams} from '/assets/js/common.js'; |
| import {Expectations} from '/assets/js/expectations.js'; |
| import {InvestigateDrawer} from '/assets/js/investigate.js'; |
| import {ToolTip} from '/assets/js/tooltip.js'; |
| import {Timeline} from '/library/js/components/TimelineComponents.js'; |
| import {DOM, EventStream, REF, FP} from '/library/js/Ref.js'; |
| |
| |
| const DEFAULT_LIMIT = 100; |
| |
| let willFilterExpected = false; |
| let showTestTimes = false; |
| |
| function minimumUuidForResults(results, limit) { |
| const now = Math.floor(Date.now() / 10); |
| let minDisplayedUuid = now; |
| let maxLimitedUuid = 0; |
| |
| Object.keys(results).forEach((key) => { |
| results[key].forEach(pair => { |
| if (!pair.results.length) |
| return; |
| if (limit !== 1 && limit === pair.results.length) |
| maxLimitedUuid = Math.max(pair.results[0].uuid, maxLimitedUuid); |
| else if (limit === 1) |
| minDisplayedUuid = Math.min(pair.results[pair.results.length - 1].uuid, minDisplayedUuid); |
| else |
| minDisplayedUuid = Math.min(pair.results[0].uuid, minDisplayedUuid); |
| }); |
| }); |
| |
| if (minDisplayedUuid === now) |
| return maxLimitedUuid; |
| return Math.max(minDisplayedUuid, maxLimitedUuid); |
| } |
| |
| function commitsForResults(results, limit, allCommits = true) { |
| const minDisplayedUuid = minimumUuidForResults(limit); |
| let commits = []; |
| let repositories = new Set(); |
| let currentCommitIndex = CommitBank.commits.length - 1; |
| Object.keys(results).forEach((key) => { |
| results[key].forEach(pair => { |
| pair.results.forEach(result => { |
| if (result.uuid < minDisplayedUuid) |
| return; |
| let candidateCommits = []; |
| |
| if (!allCommits) |
| currentCommitIndex = CommitBank.commits.length - 1; |
| while (currentCommitIndex >= 0) { |
| if (CommitBank.commits[currentCommitIndex].uuid < result.uuid) |
| break; |
| if (allCommits || CommitBank.commits[currentCommitIndex].uuid === result.uuid) |
| candidateCommits.push(CommitBank.commits[currentCommitIndex]); |
| --currentCommitIndex; |
| } |
| if (candidateCommits.length === 0 || candidateCommits[candidateCommits.length - 1].uuid !== result.uuid) |
| candidateCommits.push({ |
| id: '?', |
| uuid: result.uuid, |
| }); |
| |
| let index = 0; |
| candidateCommits.forEach(commit => { |
| if (commit.repository_id) |
| repositories.add(commit.repository_id); |
| while (index < commits.length) { |
| if (commit.uuid === commits[index].uuid) |
| return; |
| if (commit.uuid > commits[index].uuid) { |
| commits.splice(index, 0, commit); |
| return; |
| } |
| ++index; |
| } |
| commits.push(commit); |
| }); |
| }); |
| }); |
| }); |
| if (currentCommitIndex >= 0 && commits.length) { |
| let trailingRepositories = new Set(repositories); |
| trailingRepositories.delete(commits[commits.length - 1].repository_id); |
| while (currentCommitIndex >= 0 && trailingRepositories.size) { |
| const commit = CommitBank.commits[currentCommitIndex]; |
| if (trailingRepositories.has(commit.repository_id)) { |
| commits.push(commit); |
| trailingRepositories.delete(commit.repository_id); |
| } |
| --currentCommitIndex; |
| } |
| } |
| |
| repositories = [...repositories]; |
| repositories.sort(); |
| return commits; |
| } |
| |
| function scaleForCommits(commits) { |
| let scale = []; |
| for (let i = commits.length - 1; i >= 0; --i) { |
| const repository_id = commits[i].repository_id ? commits[i].repository_id : '?'; |
| scale.unshift({}); |
| scale[0][repository_id] = commits[i]; |
| if (scale.length < 2) |
| continue; |
| Object.keys(scale[1]).forEach((key) => { |
| if (key === repository_id || key === '?' || key === 'uuid') |
| return; |
| scale[0][key] = scale[1][key]; |
| }); |
| scale[0].uuid = Math.max(...Object.keys(scale[0]).map((key) => { |
| return scale[0][key].uuid; |
| })); |
| } |
| return scale; |
| } |
| |
| function repositoriesForCommits(commits) { |
| let repositories = new Set(); |
| commits.forEach((commit) => { |
| if (commit.repository_id) |
| repositories.add(commit.repository_id); |
| }); |
| repositories = [...repositories]; |
| if (!repositories.length) |
| repositories = ['?']; |
| repositories.sort(); |
| return repositories; |
| } |
| |
| function xAxisFromScale(scale, repository, updatesArray, isTop=false, viewport=null) |
| { |
| function scaleForRepository(scale) { |
| return scale.map(node => { |
| let commit = node[repository]; |
| if (!commit) |
| commit = node['?']; |
| if (!commit) |
| return {id: '', uuid: null}; |
| return commit; |
| }); |
| } |
| |
| function onScaleClick(node) { |
| if (!node.label.id) |
| return; |
| let params = { |
| branch: node.label.branch ? [node.label.branch] : queryToParams(document.URL.split('?')[1]).branch, |
| uuid: [node.label.uuid], |
| } |
| if (!params.branch) |
| delete params.branch; |
| const query = paramsToQuery(params); |
| window.open(`/commit?${query}`, '_blank'); |
| } |
| |
| return Timeline.CanvasXAxisComponent(scaleForRepository(scale), { |
| isTop: isTop, |
| height: 130, |
| onScaleClick: onScaleClick, |
| onScaleEnter: (node, event, canvas) => { |
| const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop; |
| ToolTip.set( |
| `<div class="content"> |
| Time: ${new Date(node.label.timestamp * 1000).toLocaleString()}<br> |
| Committer: ${node.label.committer} |
| ${node.label.message ? `<br><div>${escapeHTML(node.label.message.split('\n')[0])}</div>` : ''} |
| </div>`, |
| node.tipPoints.map((point) => { |
| return {x: canvas.x + point.x, y: canvas.y + scrollDelta + point.y}; |
| }), |
| (event) => {return onScaleClick(node);}, |
| viewport, |
| ); |
| }, |
| onScaleLeave: (event, canvas) => { |
| const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop; |
| if (!ToolTip.isIn({x: event.x, y: event.y - scrollDelta})) |
| ToolTip.unset(); |
| }, |
| // Per the birthday paradox, 10% change of collision with 7.7 million commits with 12 character commits |
| getLabelFunc: (commit) => {return commit ? commit.id.substring(0,12) : '?';}, |
| getScaleFunc: (commit) => commit.uuid, |
| exporter: (updateFunction) => { |
| updatesArray.push((scale) => {updateFunction(scaleForRepository(scale));}); |
| }, |
| }); |
| } |
| |
| const testsRegex = /tests_([a-z])+/; |
| const worstRegex = /worst_([a-z])+/; |
| |
| function inPlaceCombine(out, obj) |
| { |
| if (!obj) |
| return out; |
| |
| if (!out) { |
| out = {}; |
| Object.keys(obj).forEach(key => { |
| if (key[0] === '_') |
| return; |
| if (obj[key] instanceof Object) |
| out[key] = inPlaceCombine(out[key], obj[key]); |
| else |
| out[key] = obj[key]; |
| }); |
| } else { |
| Object.keys(out).forEach(key => { |
| if (key[0] === '_') |
| return; |
| |
| if (out[key] instanceof Object) { |
| out[key] = inPlaceCombine(out[key], obj[key]); |
| return; |
| } |
| |
| // Set of special case keys which need to be added together |
| if (key.match(worstRegex)) |
| return; |
| if (key.match(testsRegex)) { |
| const worstKey = `worst_${key}`; |
| out[worstKey] = Math.max( |
| out[worstKey] ? out[worstKey] : out[key], |
| obj[worstKey] ? obj[worstKey] : obj[key], |
| ); |
| out[key] += obj[key]; |
| return; |
| } |
| |
| // Some special combination logic |
| if (key === 'time') { |
| out[key] = Math.max( |
| out[key] ? out[key] : 0, |
| obj[key] ? obj[key] : 0, |
| ); |
| return; |
| } |
| |
| // If the key exists, but doesn't match, delete it |
| if (!(key in obj) || out[key] !== obj[key]) { |
| delete out[key]; |
| return; |
| } |
| }); |
| Object.keys(obj).forEach(key => { |
| if (!key.match(testsRegex)) |
| return; |
| const worstKey = `worst_${key}`; |
| if (!(key in out)) { |
| out[key] = obj[key]; |
| out[worstKey] = obj[key]; |
| } |
| out[worstKey] = Math.max(out[worstKey], obj[worstKey] ? obj[worstKey] : obj[key]); |
| }); |
| } |
| return out; |
| } |
| |
| function statsForSingleResult(result) { |
| const actualId = Expectations.stringToStateId(result.actual); |
| const unexpectedId = Expectations.stringToStateId(Expectations.unexpectedResults(result.actual, result.expected)); |
| let stats = { |
| tests_run: 1, |
| tests_skipped: 0, |
| } |
| Expectations.failureTypes.forEach(type => { |
| const idForType = Expectations.stringToStateId(Expectations.failureTypeMap[type]); |
| stats[`tests_${type}`] = actualId > idForType ? 0 : 1; |
| stats[`tests_unexpected_${type}`] = unexpectedId > idForType ? 0 : 1; |
| }); |
| return stats; |
| } |
| |
| function combineResults() { |
| let counts = new Array(arguments.length).fill(0); |
| let data = []; |
| |
| while (true) { |
| // Find candidate uuid |
| let uuid = 0; |
| for (let i = 0; i < counts.length; ++i) { |
| let candidateUuid = null; |
| while (arguments[i] && arguments[i].length > counts[i]) { |
| candidateUuid = arguments[i][counts[i]].uuid; |
| if (candidateUuid) |
| break; |
| ++counts[i]; |
| } |
| if (candidateUuid) |
| uuid = Math.max(uuid, candidateUuid); |
| } |
| |
| if (!uuid) |
| return data; |
| |
| // Combine relevant results |
| let dataNode = null; |
| for (let i = 0; i < counts.length; ++i) { |
| while (counts[i] < arguments[i].length && arguments[i][counts[i]] && arguments[i][counts[i]].uuid === uuid) { |
| if (dataNode && !dataNode.stats) |
| dataNode.stats = statsForSingleResult(dataNode); |
| |
| dataNode = inPlaceCombine(dataNode, arguments[i][counts[i]]); |
| |
| if (dataNode.stats && !arguments[i][counts[i]].stats) |
| dataNode.stats = inPlaceCombine(dataNode.stats, statsForSingleResult(arguments[i][counts[i]])); |
| |
| ++counts[i]; |
| } |
| } |
| if (dataNode) |
| data.push(dataNode); |
| } |
| return data; |
| } |
| |
| class TimelineFromEndpoint { |
| constructor(endpoint, suite = null, test = null, viewport = null) { |
| this.endpoint = endpoint; |
| this.displayAllCommits = true; |
| |
| this.configurations = Configuration.fromQuery(); |
| this.results = {}; |
| |
| // Suite and test can often be implied by the endpoint, but doing so is more confusing then helpful |
| this.suite = suite; |
| this.test = test; |
| |
| this.updates = []; |
| this.xaxisUpdates = []; |
| this.timelineUpdate = null; |
| this.notifyRerender = () => {}; |
| this.repositories = []; |
| this.viewport = viewport; |
| |
| const self = this; |
| |
| this.latestDispatch = Date.now(); |
| this.ref = REF.createRef({ |
| state: {}, |
| onStateUpdate: (element, state) => { |
| if (state.error) |
| element.innerHTML = ErrorDisplay(state); |
| else if (state > 0) |
| DOM.inject(element, this.render(state)); |
| else |
| element.innerHTML = this.placeholder(); |
| } |
| }); |
| |
| this.commit_callback = () => { |
| self.update(); |
| }; |
| CommitBank.callbacks.push(this.commit_callback); |
| |
| this.reload(); |
| } |
| teardown() { |
| CommitBank.callbacks = CommitBank.callbacks.filter((value, index, arr) => { |
| return this.commit_callback === value; |
| }); |
| } |
| update() { |
| const params = queryToParams(document.URL.split('?')[1]); |
| const commits = commitsForResults(this.results, params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT, this.allCommits); |
| const scale = scaleForCommits(commits); |
| |
| const newRepositories = repositoriesForCommits(commits); |
| let haveNewRepos = this.repositories.length !== newRepositories.length; |
| for (let i = 0; !haveNewRepos && i < this.repositories.length && i < newRepositories.length; ++i) |
| haveNewRepos = this.repositories[i] !== newRepositories[i]; |
| if (haveNewRepos && this.timelineUpdate) { |
| this.xaxisUpdates = []; |
| let top = true; |
| let components = []; |
| |
| newRepositories.forEach(repository => { |
| components.push(xAxisFromScale(scale, repository, this.xaxisUpdates, top, this.viewport)); |
| top = false; |
| }); |
| |
| this.timelineUpdate(components); |
| this.repositories = newRepositories; |
| } |
| |
| this.updates.forEach(func => {func(scale);}) |
| this.xaxisUpdates.forEach(func => {func(scale);}); |
| } |
| rerender() { |
| const params = queryToParams(document.URL.split('?')[1]); |
| this.ref.setState(params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT); |
| } |
| reload() { |
| let myDispatch = Date.now(); |
| this.latestDispatch = Math.max(this.latestDispatch, myDispatch); |
| this.ref.setState(-1); |
| |
| const self = this; |
| let sharedParams = queryToParams(document.URL.split('?')[1]); |
| Configuration.members().forEach(member => { |
| delete sharedParams[member]; |
| }); |
| delete sharedParams.suite; |
| delete sharedParams.test; |
| delete sharedParams.repository_id; |
| |
| let newConfigs = Configuration.fromQuery(); |
| if (!deepCompare(newConfigs, this.configurations)) { |
| this.configurations = newConfigs; |
| this.results = {}; |
| this.configurations.forEach(configuration => { |
| this.results[configuration.toKey()] = []; |
| }); |
| } |
| |
| this.configurations.forEach(configuration => { |
| let params = configuration.toParams(); |
| for (let key in sharedParams) |
| params[key] = sharedParams[key]; |
| const query = paramsToQuery(params); |
| |
| fetch(query ? this.endpoint + '?' + query : this.endpoint).then(response => { |
| response.json().then(json => { |
| if (myDispatch !== this.latestDispatch) |
| return; |
| |
| let oldestUuid = Date.now() / 10; |
| let newestUuid = 0; |
| self.results[configuration.toKey()] = json; |
| self.results[configuration.toKey()].sort((a, b) => { |
| const aConfig = new Configuration(a.configuration); |
| const bConfig = new Configuration(b.configuration); |
| let configCompare = aConfig.compare(bConfig); |
| if (configCompare === 0) |
| configCompare = aConfig.compareSDKs(bConfig); |
| return configCompare; |
| }); |
| self.results[configuration.toKey()].forEach(keyValue => { |
| keyValue.results.forEach(result => { |
| oldestUuid = Math.min(oldestUuid, result.uuid); |
| newestUuid = Math.max(newestUuid, result.uuid); |
| }); |
| }); |
| |
| if (oldestUuid < newestUuid) |
| CommitBank.add(oldestUuid, newestUuid); |
| |
| self.ref.setState(params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT); |
| }); |
| }).catch(error => { |
| if (myDispatch === this.latestDispatch) |
| this.ref.setState({error: "Connection Error", description: error}); |
| }); |
| }); |
| } |
| placeholder() { |
| return `<div class="loader"> |
| <div class="spinner"></div> |
| </div>`; |
| } |
| toString() { |
| this.ref = REF.createRef({ |
| state: this.ref.state, |
| onStateUpdate: (element, state) => { |
| if (state.error) |
| DOM.inject(element, ErrorDisplay(state)); |
| else if (state > 0) |
| DOM.inject(element, this.render(state)); |
| else |
| DOM.inject(element, this.placeholder()); |
| } |
| }); |
| |
| return `<div class="content" ref="${this.ref}"></div>`; |
| } |
| |
| render(limit) { |
| const branch = queryToParams(document.URL.split('?')[1]).branch; |
| const self = this; |
| const commits = commitsForResults(this.results, limit, this.allCommits); |
| const scale = scaleForCommits(commits); |
| |
| const colorMap = Expectations.colorMap(); |
| this.updates = []; |
| const options = { |
| getScaleFunc: (value) => { |
| if (value && value.uuid) |
| return {uuid: value.uuid}; |
| return {}; |
| }, |
| compareFunc: (a, b) => {return b.uuid - a.uuid;}, |
| renderFactory: (drawDot) => (data, context, x, y) => { |
| if (!data) |
| return drawDot(context, x, y, true); |
| |
| let tag = null; |
| let color = colorMap.success; |
| let symbol = Expectations.symbolMap.success; |
| if (data.stats) { |
| if (data.start_time) |
| tag = data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}failed`]; |
| else |
| tag = data.stats[`worst_tests${willFilterExpected ? '_unexpected_' : '_'}failed`]; |
| if (data.stats.worst_tests_run <= 1) |
| tag = null; |
| |
| Expectations.failureTypes.forEach(type => { |
| if (data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}${type}`] > 0) { |
| color = colorMap[type]; |
| symbol = Expectations.symbolMap[type]; |
| } |
| }); |
| } else { |
| let resultId = Expectations.stringToStateId(data.actual); |
| if (willFilterExpected) |
| resultId = Expectations.stringToStateId(Expectations.unexpectedResults(data.actual, data.expected)); |
| Expectations.failureTypes.forEach(type => { |
| if (Expectations.stringToStateId(Expectations.failureTypeMap[type]) >= resultId) { |
| color = colorMap[type]; |
| symbol = Expectations.symbolMap[type]; |
| } |
| }); |
| } |
| const time = data.time ? Math.round(data.time / 1000) : 0; |
| if (time && showTestTimes) |
| tag = time; |
| |
| return drawDot(context, x, y, false, tag ? tag : null, symbol, false, color); |
| }, |
| }; |
| |
| function onDotClickFactory(configuration) { |
| return (data) => { |
| let allData = []; |
| let partialConfiguration = {}; |
| self.configurations.forEach(configurationKey => { |
| if (configurationKey.compare(configuration) || configurationKey.compareSDKs(configuration)) |
| return; |
| self.results[configurationKey.toKey()].forEach(pair => { |
| const computedConfiguration = new Configuration(pair.configuration); |
| if (computedConfiguration.compare(configuration) || computedConfiguration.compareSDKs(configuration)) |
| return; |
| let doesMatch = false; |
| pair.results.forEach(node => { |
| if (node.uuid !== data.uuid) |
| return; |
| doesMatch = true; |
| let dataNode = {}; |
| Object.keys(node).forEach(key => { |
| dataNode[key] = node[key]; |
| }); |
| dataNode['configuration'] = computedConfiguration; |
| allData.push(dataNode); |
| }); |
| if (doesMatch) { |
| Configuration.members().forEach(member => { |
| if (member in partialConfiguration) { |
| if (partialConfiguration[member] !== null && partialConfiguration[member] !== computedConfiguration[member]) |
| partialConfiguration[member] = null; |
| } else if (computedConfiguration[member] !== null) |
| partialConfiguration[member] = computedConfiguration[member]; |
| }); |
| } |
| }); |
| }); |
| let agregateData = {}; |
| Object.keys(data).forEach(key => { |
| agregateData[key] = data[key]; |
| }); |
| agregateData['configuration'] = new Configuration(partialConfiguration); |
| ToolTip.unset(); |
| InvestigateDrawer.expand(self.suite, agregateData, allData); |
| } |
| } |
| |
| function onDotEnterFactory(configuration) { |
| return (data, event, canvas) => { |
| let partialConfiguration = {}; |
| self.configurations.forEach(configurationKey => { |
| if (configurationKey.compare(configuration) || configurationKey.compareSDKs(configuration)) |
| return; |
| self.results[configurationKey.toKey()].forEach(pair => { |
| const computedConfiguration = new Configuration(pair.configuration); |
| if (computedConfiguration.compare(configuration) || computedConfiguration.compareSDKs(configuration)) |
| return; |
| let doesMatch = false; |
| pair.results.forEach(node => { |
| if (doesMatch) |
| return; |
| if (node.uuid == data.uuid) |
| doesMatch = true; |
| }); |
| if (doesMatch) { |
| Configuration.members().forEach(member => { |
| if (member in partialConfiguration) { |
| if (partialConfiguration[member] !== null && partialConfiguration[member] !== computedConfiguration[member]) |
| partialConfiguration[member] = null; |
| } else if (computedConfiguration[member] !== null) |
| partialConfiguration[member] = computedConfiguration[member]; |
| }); |
| } |
| }); |
| }); |
| partialConfiguration = new Configuration(partialConfiguration); |
| const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop; |
| |
| const buildParams = configuration.toParams(); |
| buildParams['suite'] = [self.suite]; |
| buildParams['uuid'] = [data.uuid]; |
| buildParams['after_time'] = [data.start_time]; |
| buildParams['before_time'] = [data.start_time]; |
| if (branch) |
| buildParams['branch'] = branch; |
| |
| ToolTip.set( |
| `<div class="content"> |
| ${data.start_time ? `<a href="/urls/build?${paramsToQuery(buildParams)}" target="_blank">Test run</a> @ ${new Date(data.start_time * 1000).toLocaleString()}<br>` : ''} |
| ${data.start_time && ArchiveRouter.hasArchive(self.suite, data.actual) ? `<a href="/archive/${ArchiveRouter.pathFor(self.suite, data.actual, self.test)}?${paramsToQuery(buildParams)}" target="_blank">${ArchiveRouter.labelFor(self.suite, data.actual)}</a><br>` : ''} |
| Commits: ${CommitBank.commitsDuring(data.uuid).map((commit) => { |
| let params = { |
| branch: commit.branch ? [commit.branch] : branch, |
| uuid: [commit.uuid], |
| } |
| if (!params.branch) |
| delete params.branch; |
| const query = paramsToQuery(params); |
| return `<a href="/commit/info?${query}" target="_blank">${commit.id.substring(0,12)}</a>`; |
| }).join(', ')} |
| <br> |
| ${partialConfiguration} |
| <br> |
| ${data.stats ? `<a href="/investigate?${paramsToQuery(buildParams)}" target="_blank">Investigate failures</a><br>` : ''} |
| ${data.expected ? `Expected: ${data.expected}<br>` : ''} |
| ${data.actual ? `Actual: ${data.actual}<br>` : ''} |
| </div>`, |
| data.tipPoints.map((point) => { |
| return {x: canvas.x + point.x, y: canvas.y + scrollDelta + point.y}; |
| }), |
| (event) => {onDotClickFactory(configuration)(data);}, |
| self.viewport, |
| ); |
| } |
| } |
| |
| function onDotLeave(event, canvas) { |
| const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop; |
| if (!ToolTip.isIn({x: event.pageX, y: event.pageY - scrollDelta})) |
| ToolTip.unset(); |
| } |
| |
| function exporterFactory(data) { |
| return (updateFunction) => { |
| self.updates.push((scale) => {updateFunction(data, scale);}); |
| } |
| } |
| |
| let children = []; |
| this.configurations.forEach(configuration => { |
| if (!this.results[configuration.toKey()] || Object.keys(this.results[configuration.toKey()]).length === 0) |
| return; |
| |
| // Create a list of configurations to display with SDKs stripped |
| let mappedChildrenConfigs = {}; |
| let childrenConfigsBySDK = {} |
| let resultsByKey = {}; |
| this.results[configuration.toKey()].forEach(pair => { |
| const strippedConfig = new Configuration(pair.configuration); |
| resultsByKey[strippedConfig.toKey()] = combineResults([], [...pair.results].sort(function(a, b) {return b.uuid - a.uuid;})); |
| strippedConfig.sdk = null; |
| mappedChildrenConfigs[strippedConfig.toKey()] = strippedConfig; |
| if (!childrenConfigsBySDK[strippedConfig.toKey()]) |
| childrenConfigsBySDK[strippedConfig.toKey()] = []; |
| childrenConfigsBySDK[strippedConfig.toKey()].push(new Configuration(pair.configuration)); |
| }); |
| let childrenConfigs = []; |
| Object.keys(mappedChildrenConfigs).forEach(key => { |
| childrenConfigs.push(mappedChildrenConfigs[key]); |
| }); |
| childrenConfigs.sort(function(a, b) {return a.compare(b);}); |
| |
| // Create the collapsed timelines, cobine results |
| let allResults = []; |
| let collapsedTimelines = []; |
| childrenConfigs.forEach(config => { |
| childrenConfigsBySDK[config.toKey()].sort(function(a, b) {return a.compareSDKs(b);}); |
| |
| let resultsForConfig = []; |
| childrenConfigsBySDK[config.toKey()].forEach(sdkConfig => { |
| resultsForConfig = combineResults(resultsForConfig, resultsByKey[sdkConfig.toKey()]); |
| }); |
| allResults = combineResults(allResults, resultsForConfig); |
| |
| let queueParams = config.toParams(); |
| queueParams['suite'] = [this.suite]; |
| if (branch) |
| queueParams['branch']; |
| let myTimeline = Timeline.SeriesWithHeaderComponent( |
| `${childrenConfigsBySDK[config.toKey()].length > 1 ? ' | ' : ''}<a href="/urls/queue?${paramsToQuery(queueParams)}" target="_blank">${config}</a>`, |
| Timeline.CanvasSeriesComponent(resultsForConfig, scale, { |
| getScaleFunc: options.getScaleFunc, |
| compareFunc: options.compareFunc, |
| renderFactory: options.renderFactory, |
| exporter: options.exporter, |
| onDotClick: onDotClickFactory(config), |
| onDotEnter: onDotEnterFactory(config), |
| onDotLeave: onDotLeave, |
| exporter: exporterFactory(resultsForConfig), |
| })); |
| |
| if (childrenConfigsBySDK[config.toKey()].length > 1) { |
| let timelinesBySDK = []; |
| childrenConfigsBySDK[config.toKey()].forEach(sdkConfig => { |
| timelinesBySDK.push( |
| Timeline.SeriesWithHeaderComponent(`${Configuration.integerToVersion(sdkConfig.version)} (${sdkConfig.sdk})`, |
| Timeline.CanvasSeriesComponent(resultsByKey[sdkConfig.toKey()], scale, { |
| getScaleFunc: options.getScaleFunc, |
| compareFunc: options.compareFunc, |
| renderFactory: options.renderFactory, |
| exporter: options.exporter, |
| onDotClick: onDotClickFactory(sdkConfig), |
| onDotEnter: onDotEnterFactory(sdkConfig), |
| onDotLeave: onDotLeave, |
| exporter: exporterFactory(resultsByKey[sdkConfig.toKey()]), |
| }))); |
| }); |
| myTimeline = Timeline.ExpandableSeriesWithHeaderExpanderComponent(myTimeline, {}, ...timelinesBySDK); |
| } |
| collapsedTimelines.push(myTimeline); |
| }); |
| |
| if (collapsedTimelines.length === 0) |
| return; |
| if (collapsedTimelines.length === 1) { |
| if (!collapsedTimelines[0].header.includes('class="series"')) |
| collapsedTimelines[0].header = Timeline.HeaderComponent(collapsedTimelines[0].header); |
| children.push(collapsedTimelines[0]); |
| return; |
| } |
| |
| children.push( |
| Timeline.ExpandableSeriesWithHeaderExpanderComponent( |
| Timeline.SeriesWithHeaderComponent(` ${configuration}`, |
| Timeline.CanvasSeriesComponent(allResults, scale, { |
| getScaleFunc: options.getScaleFunc, |
| compareFunc: options.compareFunc, |
| renderFactory: options.renderFactory, |
| onDotClick: onDotClickFactory(configuration), |
| onDotEnter: onDotEnterFactory(configuration), |
| onDotLeave: onDotLeave, |
| exporter: exporterFactory(allResults), |
| })), |
| {expanded: this.configurations.length <= 1}, |
| ...collapsedTimelines |
| )); |
| }); |
| |
| let top = true; |
| self.xaxisUpdates = []; |
| this.repositories = repositoriesForCommits(commits); |
| this.repositories.forEach(repository => { |
| const xAxisComponent = xAxisFromScale(scale, repository, self.xaxisUpdates, top, self.viewport); |
| if (top) |
| children.unshift(xAxisComponent); |
| else |
| children.push(xAxisComponent); |
| top = false; |
| }); |
| |
| const composer = FP.composer(FP.currying((updateTimeline, notifyRerender) => { |
| self.timelineUpdate = (xAxises) => { |
| children.splice(0, 1); |
| if (self.repositories.length > 1) |
| children.splice(children.length - self.repositories.length, self.repositories.length); |
| |
| let top = true; |
| xAxises.forEach(component => { |
| if (top) |
| children.unshift(component); |
| else |
| children.push(component); |
| top = false; |
| }); |
| updateTimeline(children); |
| }; |
| self.notifyRerender = notifyRerender; |
| })); |
| return Timeline.CanvasContainer(composer, ...children); |
| } |
| } |
| |
| |
| function LegendLabel(eventStream, filterExpectedText, filterUnexpectedText) { |
| let ref = REF.createRef({ |
| state: willFilterExpected, |
| onStateUpdate: (element, state) => { |
| if (state) element.innerText = filterExpectedText; |
| else element.innerText = filterUnexpectedText; |
| } |
| }); |
| eventStream.action((willFilterExpected) => ref.setState(willFilterExpected)); |
| return `<div class="label" style="font-size: var(--smallSize)" ref="${ref}"></div>`; |
| } |
| |
| function Legend(callback=null, plural=false, defaultWillFilterExpected=false) { |
| willFilterExpected = defaultWillFilterExpected; |
| InvestigateDrawer.willFilterExpected = willFilterExpected; |
| let updateLabelEvents = new EventStream(); |
| const legendDetails = { |
| success: { |
| expected: plural ? 'No unexpected results' : 'Result expected', |
| unexpected: plural ? 'All tests passed' : 'Test passed', |
| }, |
| warning: { |
| expected: plural ? 'Some tests unexpectedly reported warnings' : 'Unexpected warning', |
| unexpected: plural ? 'Some tests reported warnings' : 'Test warning', |
| }, |
| failed: { |
| expected: plural ? 'Some tests unexpectedly failed' : 'Unexpectedly failed', |
| unexpected: plural ? 'Some tests failed' : 'Test failed', |
| }, |
| timedout: { |
| expected: plural ? 'Some tests unexpectedly timed out' : 'Unexpectedly timed out', |
| unexpected: plural ? 'Some tests timed out' : 'Test timed out', |
| }, |
| crashed: { |
| expected: plural ? 'Some tests unexpectedly crashed' : 'Unexpectedly crashed', |
| unexpected: plural ? 'Some tests crashed' : 'Test crashed', |
| }, |
| }; |
| let result = `<div class="lengend horizontal"> |
| ${Object.keys(legendDetails).map((key) => { |
| const dot = REF.createRef({ |
| onElementMount: (element) => { |
| element.addEventListener('mouseleave', (event) => { |
| if (!ToolTip.isIn({x: event.x, y: event.y})) |
| ToolTip.unset(); |
| }); |
| element.onmouseover = (event) => { |
| if (!element.classList.contains('disabled')) |
| return; |
| ToolTip.setByElement( |
| `<div class="content"> |
| ${willFilterExpected ? legendDetails[key].expected : legendDetails[key].unexpected} |
| </div>`, |
| element, |
| {orientation: ToolTip.HORIZONTAL}, |
| ); |
| }; |
| } |
| }); |
| return `<div class="item"> |
| <div class="dot ${key}" ref="${dot}"><div class="text">${Expectations.symbolMap[key]}</div></div> |
| ${LegendLabel(updateLabelEvents, legendDetails[key].expected, legendDetails[key].unexpected)} |
| </div>` |
| }).join('')} |
| </div>`; |
| |
| if (callback) { |
| const filterSwitch = REF.createRef({ |
| onElementMount: (element) => { |
| element.onchange = () => { |
| if (element.checked) |
| willFilterExpected = true; |
| else |
| willFilterExpected = false; |
| updateLabelEvents.add(willFilterExpected); |
| InvestigateDrawer.willFilterExpected = willFilterExpected; |
| InvestigateDrawer.dispatch(); |
| InvestigateDrawer.select(InvestigateDrawer.selected); |
| callback(willFilterExpected); |
| }; |
| }, |
| }); |
| const showTimesSwitch = REF.createRef({ |
| onElementMount: (element) => { |
| element.onchange = () => { |
| if (element.checked) |
| showTestTimes = true; |
| else |
| showTestTimes = false; |
| callback(); |
| }; |
| }, |
| }); |
| |
| result += `<div class="input"> |
| <label>Filter expected results</label> |
| <label class="switch"> |
| <input type="checkbox"${willFilterExpected ? ' checked': ''} ref="${filterSwitch}"> |
| <span class="slider"></span> |
| </label> |
| </div>` |
| if (!plural) |
| result += `<div class="input"> |
| <label>Show test times</label> |
| <label class="switch"> |
| <input type="checkbox"${showTestTimes ? ' checked': ''} ref="${showTimesSwitch}"> |
| <span class="slider"></span> |
| </label> |
| </div>`; |
| } |
| |
| return `${result}`; |
| } |
| |
| export {Legend, TimelineFromEndpoint, Expectations}; |