diff --git a/playwright.config.ts b/playwright.config.ts index 3fe770c56..da1df9afe 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,8 +10,10 @@ import { defineConfig, devices } from '@playwright/test'; * Test files must be prefixed with 'mobile_', 'desktop_' or 'all_' */ const Platforms = { - mobile: /(mobile)/, + mobile: /(mobile|(desktop.*@Mobile))/, desktop: /(desktop)/, + mobileTag: /@Mobile/, + desktopTag: /@Desktop/ } /** @@ -55,24 +57,28 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, grep: Platforms.desktop, + grepInvert: Platforms.mobileTag }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, grep: Platforms.desktop, + grepInvert: Platforms.mobileTag }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, grep: Platforms.desktop, + grepInvert: Platforms.mobileTag }, { name: 'edge', use: { ...devices['Desktop Edge'] }, grep: Platforms.desktop, + grepInvert: Platforms.mobileTag }, /* Test against mobile viewports. */ @@ -80,11 +86,13 @@ export default defineConfig({ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] }, grep: Platforms.mobile, + grepInvert: Platforms.desktopTag }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] }, grep: Platforms.mobile, + grepInvert: Platforms.desktopTag }, ], diff --git a/resources/js/Tiling/Layer/TileLayer.js b/resources/js/Tiling/Layer/TileLayer.js index b7bbf34af..4035ab022 100644 --- a/resources/js/Tiling/Layer/TileLayer.js +++ b/resources/js/Tiling/Layer/TileLayer.js @@ -53,6 +53,7 @@ var TileLayer = Layer.extend( this.baseDiffTime = (typeof baseDiffTime == 'undefined' ? $('#date').val()+' '+$('#time').val() : baseDiffTime); this.name = name; this.tileVisibilityRange = {xStart: 0, xEnd: 0, yStart: 0, yEnd: 0}; + this.tileset = 0; // Mapping of x, y coordinates to HTML img tags this._tiles = {} }, @@ -360,18 +361,27 @@ var TileLayer = Layer.extend( this.sharpen = !this.sharpen; }, + _updateTileset: function (tileset) { + if (tileset > this.tileset) { + this.tileset = tileset; + } + }, + /** * @description Generates URL to retrieve a single Tile and displays the transparent tile if request fails * @param {Int} x Tile X-coordinate * @param {Int} y Tile Y-coordinate + * @param {Int} tileset Number representing the current set of tiles * @param {Function} onTileLoadComplete -- callback function to this.tileLoader.onTileLoadComplete * @returns {String} URL to retrieve the requested tile * * IE: CSS opacities do not behave properly with absolutely positioned elements. Opacity is therefor * set at tile-level. */ - getTile: function (event, x, y, onTileLoadComplete) { + getTile: function (event, x, y, tileset, onTileLoadComplete) { var top, left, ts, img, rf, emptyTile; + // Track the latest tileset + this._updateTileset(tileset); left = x * this.tileSize; top = y * this.tileSize; @@ -389,14 +399,25 @@ var TileLayer = Layer.extend( img.css("opacity", this.opacity / 100); } + const renderTile = () => { + if (tileset >= this.tileset) { + this._replaceTile(x, y, img); + if (onTileLoadComplete) { + onTileLoadComplete(); + } + } else { + // remove stale tile. + $(img).remove(); + } + } + // Load tile - let layer = this; img.on('error', function (e) { - layer._replaceTile(x, y, this); + renderTile(); img.unbind("error"); $(this).attr("src", emptyTile); }).on('load', function () { - layer._replaceTile(x, y, this); + renderTile(); $(this).width(512).height(512); // Wait until image is done loading specify dimensions in order to prevent // Firefox from displaying place-holders }).attr("src", this.getTileURL(x, y)); @@ -404,9 +425,6 @@ var TileLayer = Layer.extend( // Makes sure all of the images have finished downloading before swapping them in img.appendTo(this.domNode); - if (onTileLoadComplete) { - img.on('load', onTileLoadComplete); - } }, /** diff --git a/resources/js/Tiling/Layer/TileLoader.js b/resources/js/Tiling/Layer/TileLoader.js index 1165d7ae6..6224837d5 100644 --- a/resources/js/Tiling/Layer/TileLoader.js +++ b/resources/js/Tiling/Layer/TileLoader.js @@ -23,6 +23,16 @@ var TileLoader = Class.extend( this.loadedTiles = {}; this.width = 0; this.height = 0; + /** + * This tileset ID is a unique ID which is incremented each time + * all current tiles are reloaded. This happens when the user changes + * the current data source, or when the user zooms in and out. It is + * used to deal with a race condition where tiles from an older tileset + * may finish loading after the newer tiles are loaded. We use this + * tileset ID to choose which tile should be displayed in the event + * of this race condition. + */ + this.tileSetId = 1; this.tileVisibilityRange = tileVisibilityRange; }, @@ -106,7 +116,7 @@ var TileLoader = Class.extend( } if (!this.loadedTiles[i][j] && this.validTiles[i][j]) { this.loadedTiles[i][j] = true; - $(this.domNode).trigger('get-tile', [i, j]); + $(this.domNode).trigger('get-tile', [i, j, this.tileSetId]); } } } @@ -144,6 +154,7 @@ var TileLoader = Class.extend( * @param {Boolean} removeOldTilesFirst Whether old tiles should be removed before or after new ones are loaded. */ reloadTiles: function (removeOldTilesFirst) { + this.tileSetId += 1; this.removeOldTilesFirst = removeOldTilesFirst; this.numTilesLoaded = 0; this.loadedTiles = {}; @@ -161,7 +172,7 @@ var TileLoader = Class.extend( this._iterateVisibilityRange(this.tileVisibilityRange, (i, j) => { if (this.validTiles[i] && this.validTiles[i][j]) { this.numTiles += 1; - $(this.domNode).trigger('get-tile', [i, j, $.proxy(this.onTileLoadComplete, this)]); + $(this.domNode).trigger('get-tile', [i, j, this.tileSetId, $.proxy(this.onTileLoadComplete, this)]); if (!this.loadedTiles[i]) { this.loadedTiles[i] = {}; @@ -193,6 +204,7 @@ var TileLoader = Class.extend( }, onTileLoadComplete: function () { + // Only count tiles matching the latest tileset this.numTilesLoaded += 1; // After all tiles have loaded, stop indicator (and remove old-tiles if haven't already) diff --git a/tests/desktop/multi/tiles.spec.ts b/tests/desktop/multi/tiles.spec.ts index 33a709356..7de4f6603 100644 --- a/tests/desktop/multi/tiles.spec.ts +++ b/tests/desktop/multi/tiles.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; -import { HelioviewerInterfaceFactory, HelioviewerViews } from "../../page_objects/helioviewer_interface"; +import { EmbedView, HelioviewerFactory, MinimalView, DesktopView } from "../../page_objects/helioviewer_interface"; -HelioviewerViews.forEach((view) => { +[EmbedView, MinimalView, DesktopView].forEach((view) => { /** * A recurring issue in Helioviewer deals with computing which tiles should * be displayed in the viewport based on the screen size, zoom amount, and @@ -17,32 +17,34 @@ HelioviewerViews.forEach((view) => { * This test verifies that the black space does NOT remain, and that the tile does get loaded * when it is dragged into the viewport. */ - test(`[${view}] Verify image tiles are loaded when the viewport pans to tile boundaries after zooming in and out`, async ({ - page - }) => { - let hv = HelioviewerInterfaceFactory.Create(view, page); - await hv.Load("/"); - await hv.CloseAllNotifications(); - // Zoom in to increase the number of tiles. - await hv.ZoomIn(4); - // Zoom out, to test the zoom out - await hv.ZoomOut(1); - // Tiles in column x=1 should be visible from y range y=-2 to y=1 - await expect(page.locator("//img[contains(@src, 'x=1&y=-2')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=1&y=-1')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=1&y=0')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=1&y=1')]")).toHaveCount(1); - // Same for tiles in column x=2 - await expect(page.locator("//img[contains(@src, 'x=2&y=-2')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=2&y=-1')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=2&y=0')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=2&y=1')]")).toHaveCount(1); - // Tiles in row y=1 should be visible from x range x=-3 to x=2 - await expect(page.locator("//img[contains(@src, 'x=-3&y=1')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=-2&y=1')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=-1&y=1')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=0&y=1')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=1&y=1')]")).toHaveCount(1); - await expect(page.locator("//img[contains(@src, 'x=2&y=1')]")).toHaveCount(1); - }); + test( + `[${view.name}] Verify image tiles are loaded when the viewport pans to tile boundaries after zooming in and out`, + { tag: view.tag }, + async ({ page }, info) => { + let hv = HelioviewerFactory.Create(view, page, info); + await hv.Load("/"); + await hv.CloseAllNotifications(); + // Zoom in to increase the number of tiles. + await hv.ZoomIn(4); + // Zoom out, to test the zoom out + await hv.ZoomOut(1); + // Tiles in column x=1 should be visible from y range y=-2 to y=1 + await expect(page.locator("//img[contains(@src, 'x=1&y=-2')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=1&y=-1')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=1&y=0')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=1&y=1')]")).toHaveCount(1); + // Same for tiles in column x=2 + await expect(page.locator("//img[contains(@src, 'x=2&y=-2')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=2&y=-1')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=2&y=0')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=2&y=1')]")).toHaveCount(1); + // Tiles in row y=1 should be visible from x range x=-3 to x=2 + await expect(page.locator("//img[contains(@src, 'x=-3&y=1')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=-2&y=1')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=-1&y=1')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=0&y=1')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=1&y=1')]")).toHaveCount(1); + await expect(page.locator("//img[contains(@src, 'x=2&y=1')]")).toHaveCount(1); + } + ); }); diff --git a/tests/desktop/normal/observation_date/jump_with_time_range.spec.ts b/tests/desktop/normal/observation_date/jump_with_time_range.spec.ts index d12dc4a73..7d1d0b302 100644 --- a/tests/desktop/normal/observation_date/jump_with_time_range.spec.ts +++ b/tests/desktop/normal/observation_date/jump_with_time_range.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { Helioviewer } from "../../../page_objects/helioviewer"; +import { DesktopView, HelioviewerFactory, MobileInterface, MobileView } from "page_objects/helioviewer_interface"; const time_jump_ranges = [ { jump_label: "1Min", seconds: 60 }, @@ -8,158 +8,177 @@ const time_jump_ranges = [ { jump_label: "1Year", seconds: 31556926 } ]; -time_jump_ranges.forEach(({ jump_label, seconds }) => { - /** - * This test is testing jumping backwads functionality with given label for range select-box - */ - test( - "Jump backwards with " + jump_label + " should go to matching datetime in past, with matching screenshots", - { tag: "@production" }, - async ({ page, context, browser }, info) => { - const hv = new Helioviewer(page, info); - - // 1. LOAD HV - await hv.Load(); - await hv.CloseAllNotifications(); - await hv.OpenSidebar(); - - // 2. LAYER 0 , SWITH TO SOHO - const layer = await hv.getImageLayer(0); - await layer.set("Observatory:", "SOHO"); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 3. USE NEWEST SOHO - await hv.UseNewestImage(); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 4. MARK TIME BEFORE JUMP BACKWARDS - const dateBeforeJump = await hv.GetLoadedDate(); - - // 5. JUMP BACKWARDS WITH GIVEN SECONDS - await hv.JumpBackwardsDateWithSelection(seconds); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 6. MARK TIME AFTER JUMP BACKWARDS - const dateAfterJump = await hv.GetLoadedDate(); - - // 7. ASSERT JUMPED TIME, SHOULD BE EXACTLY GIVEN SECONDSp - await expect(seconds * 1000).toBe(dateBeforeJump.getTime() - dateAfterJump.getTime()); - - // 8. SAVE CURRENT SCREENSHOT TO COMPARE LATER - const afterJumpScreenshot = await hv.saveScreenshot("after_jump_screenshot", { - style: "#helioviewer-viewport-container-outer {z-index:200000}" - }); - - // 9. START FRESH AND RELOAD HV - await page.evaluate(() => localStorage.clear()); - await hv.Load(); - await hv.CloseAllNotifications(); - await hv.OpenSidebar(); - - // 10. LAYER 0 , SWITH TO SOHO - const new_page_layer = await hv.getImageLayer(0); - await new_page_layer.set("Observatory:", "SOHO"); - await hv.WaitForLoadingComplete(); - - // 11. USE NEWEST SOHO - await hv.UseNewestImage(); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 12. LOAD THE JUMPED DATETIME, SO WE CAN COMPARE SCREENSHOT - await hv.SetObservationDateTimeFromDate(dateAfterJump); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 13. GET CURRENT SCREENSHOT TO COMPARE PREVIOUS SCREENSHOT - const directDateScreenshot = await hv.saveScreenshot("direct_date_screenshot", { - style: "#helioviewer-viewport-container-outer {z-index:200000}" - }); - - // 14, 2 SCREENSHOTS ARE FROM SAME DATE, AND SHOULD MATCH - await expect(directDateScreenshot).toBe(afterJumpScreenshot); - } - ); - - /** - * This test is testing jumping forward functionality with given label for range select-box - */ - test( - "Jump forwards with " + jump_label + " should go to matching datetime in future, with matching screenshots", - { tag: "@production" }, - async ({ page, context, browser }, info) => { - const hv = new Helioviewer(page, info); - - // 1. LOAD HV - await hv.Load(); - await hv.CloseAllNotifications(); - await hv.OpenSidebar(); - - // 2. LAYER 0 , SWITH TO SOHO - const layer = await hv.getImageLayer(0); - await layer.set("Observatory:", "SOHO"); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 3. REGISTER INITIAL DATE - const initialDate = await hv.GetLoadedDate(); - - // 3. TO TEST GO FORWARD WE ARE GOING BACK GIVEN SECONDS + SOME RANDOM TIME - const randomMilliseconds = Math.floor(Math.random() * 90) * (24 * 60 * 60 * 1000); - const wayBackInTime = new Date(); - wayBackInTime.setTime(initialDate.getTime() - seconds * 1000 - randomMilliseconds); - - // 4. NOW GO BACK TO THIS DATE - await hv.SetObservationDateTimeFromDate(wayBackInTime); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 5. NOW JUMP FORWARD WITH GIVEN SECONDS - await hv.JumpForwardDateWithSelection(seconds); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 6. NOW REGISTER THIS DATE - const dateAfterJumpForward = await hv.GetLoadedDate(); - - // 7, ASSERT WE STILL NEED TO GO RANDOM TIME TO GO WHERE WE STARTED - await expect(randomMilliseconds).toBe(initialDate.getTime() - dateAfterJumpForward.getTime()); - - // 8. TAKE A PICTURE , WE WILL COMPARE LATER - const navigatedDateScreenshot = await hv.saveScreenshot("navigated_date_screenshot", { - mask: [page.locator("#timestep-select")] - }); - - // 9. RELOAD HV WITH FRESH DATA - await page.evaluate(() => localStorage.clear()); - await hv.Load(); - await hv.CloseAllNotifications(); - await hv.OpenSidebar(); - - // 10.. LAYER 0 , SWITH TO SOHO - const layer_2 = await hv.getImageLayer(0); - await layer_2.set("Observatory:", "SOHO"); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 11. CALCULATE THE DATE WE GO TO COMPARE SCREENSHOTS - const navigatedDate = new Date(); - navigatedDate.setTime(initialDate.getTime() - randomMilliseconds); - - // 12. GO TO THAT DATE - await hv.SetObservationDateTimeFromDate(navigatedDate); - await hv.WaitForLoadingComplete(); - await hv.CloseAllNotifications(); - - // 13. TAKE A SCREENSOTi AND COMPARE - const directDateScreenshot = await hv.saveScreenshot("direct_date_screenshot", { - mask: [page.locator("#timestep-select")] - }); - - await expect(directDateScreenshot).toBe(navigatedDateScreenshot); - } - ); +[MobileView, DesktopView].forEach((view) => { + time_jump_ranges.forEach(({ jump_label, seconds }) => { + /** + * This test is testing jumping backwards functionality with given label for range select-box + * + * Marked as flaky on Mobile because it appears that localStorage.clear() + * is not always working. It could be that Helioviewer is saving state + * between the localStorage.clear() call and reloading the page. But the + * result is that the the page reloads with the old state, while the test + * is expecting to have a fresh new state. Ultimately the visual comparison + * fails. In testing, this appears to only be a mobile problem. + */ + test( + `[${view.name}] Jump backwards with ${jump_label} should go to matching datetime in past, with matching screenshots`, + { tag: ["@production", view.tag, "@flaky"] }, + async ({ page, context, browser }, info) => { + const hv = HelioviewerFactory.Create(view, page, info) as MobileInterface; + + // 1. LOAD HV + await hv.Load(); + await hv.CloseAllNotifications(); + await hv.OpenImageLayerDrawer(); + + // 2. LAYER 0 , SWITH TO SOHO + const layer = await hv.getImageLayer(0); + await layer.set("Observatory:", "SOHO"); + await hv.CloseDrawer(); + await hv.ZoomOut(3); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 3. USE NEWEST SOHO + await hv.UseNewestImage(); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 4. MARK TIME BEFORE JUMP BACKWARDS + const dateBeforeJump = await hv.GetLoadedDate(); + + // 5. JUMP BACKWARDS WITH GIVEN SECONDS + await hv.JumpBackwardsDateWithSelection(seconds); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 6. MARK TIME AFTER JUMP BACKWARDS + const dateAfterJump = await hv.GetLoadedDate(); + + // 7. ASSERT JUMPED TIME, SHOULD BE EXACTLY GIVEN SECONDSp + await expect(seconds * 1000).toBe(dateBeforeJump.getTime() - dateAfterJump.getTime()); + + // 8. SAVE CURRENT SCREENSHOT TO COMPARE LATER + await hv.saveScreenshot(`after-jump-screenshot-${jump_label}.png`, { + style: "#helioviewer-viewport-container-outer {z-index:200000}" + }); + + // 9. START FRESH AND RELOAD HV + await page.evaluate(() => localStorage.clear()); + await hv.Load(); + await hv.CloseAllNotifications(); + await hv.OpenImageLayerDrawer(); + + // 10. LAYER 0 , SWITH TO SOHO + const new_page_layer = await hv.getImageLayer(0); + await new_page_layer.set("Observatory:", "SOHO"); + await hv.CloseDrawer(); + await hv.ZoomOut(3); + + // 11. USE NEWEST SOHO + await hv.UseNewestImage(); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 12. LOAD THE JUMPED DATETIME, SO WE CAN COMPARE SCREENSHOT + await hv.SetObservationDateTimeFromDate(dateAfterJump); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 13. GET CURRENT SCREENSHOT TO COMPARE PREVIOUS SCREENSHOT + const directDateScreenshot = await hv.saveScreenshot("direct_date_screenshot", { + style: "#helioviewer-viewport-container-outer {z-index:200000}" + }); + + // 14, 2 SCREENSHOTS ARE FROM SAME DATE, AND SHOULD MATCH + // await expect(directDateScreenshot).toBe(afterJumpScreenshot); + const ss1 = Buffer.from(directDateScreenshot, "base64"); + expect(ss1).toMatchSnapshot(`after-jump-screenshot-${jump_label}.png`); + } + ); + + /** + * This test is testing jumping forward functionality with given label for range select-box + */ + test( + `[${view.name}] Jump forwards with ${jump_label} should go to matching datetime in future, with matching screenshots`, + { tag: ["@production", view.tag] }, + async ({ page, context, browser }, info) => { + const hv = HelioviewerFactory.Create(view, page, info) as MobileInterface; + + // 1. LOAD HV + await hv.Load(); + await hv.CloseAllNotifications(); + await hv.OpenImageLayerDrawer(); + + // 2. LAYER 0 , SWITH TO SOHO + const layer = await hv.getImageLayer(0); + await layer.set("Observatory:", "SOHO"); + await hv.CloseDrawer(); + await hv.ZoomOut(3); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 3. REGISTER INITIAL DATE + const initialDate = await hv.GetLoadedDate(); + + // 3. TO TEST GO FORWARD WE ARE GOING BACK GIVEN SECONDS + SOME RANDOM TIME + const randomMilliseconds = Math.floor(Math.random() * 90) * (24 * 60 * 60 * 1000); + const wayBackInTime = new Date(); + wayBackInTime.setTime(initialDate.getTime() - seconds * 1000 - randomMilliseconds); + + // 4. NOW GO BACK TO THIS DATE + await hv.SetObservationDateTimeFromDate(wayBackInTime); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 5. NOW JUMP FORWARD WITH GIVEN SECONDS + await hv.JumpForwardDateWithSelection(seconds); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 6. NOW REGISTER THIS DATE + const dateAfterJumpForward = await hv.GetLoadedDate(); + + // 7, ASSERT WE STILL NEED TO GO RANDOM TIME TO GO WHERE WE STARTED + await expect(randomMilliseconds).toBe(initialDate.getTime() - dateAfterJumpForward.getTime()); + + // 8. TAKE A PICTURE , WE WILL COMPARE LATER + await hv.saveScreenshot(`navigated-date-screenshot-${jump_label}.png`, { + mask: [page.locator("#timestep-select")] + }); + + // 9. RELOAD HV WITH FRESH DATA + await page.evaluate(() => localStorage.clear()); + await hv.Load(); + await hv.CloseAllNotifications(); + await hv.OpenImageLayerDrawer(); + + // 10. LAYER 0, SWITH TO SOHO + const layer_2 = await hv.getImageLayer(0); + await layer_2.set("Observatory:", "SOHO"); + await hv.CloseDrawer(); + await hv.ZoomOut(3); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 11. CALCULATE THE DATE WE GO TO COMPARE SCREENSHOTS + const navigatedDate = new Date(); + navigatedDate.setTime(initialDate.getTime() - randomMilliseconds); + + // 12. GO TO THAT DATE + await hv.SetObservationDateTimeFromDate(navigatedDate); + await hv.WaitForLoadingComplete(); + await hv.CloseAllNotifications(); + + // 13. TAKE A SCREENSHOT AND COMPARE + const directDateScreenshot = await hv.saveScreenshot("direct_date_screenshot", { + mask: [page.locator("#timestep-select")] + }); + + const ss = Buffer.from(directDateScreenshot, "base64"); + expect(ss).toMatchSnapshot(`navigated-date-screenshot-${jump_label}.png`); + } + ); + }); }); diff --git a/tests/page_objects/helioviewer.ts b/tests/page_objects/helioviewer.ts index 33014ef75..c5e1bd2f7 100644 --- a/tests/page_objects/helioviewer.ts +++ b/tests/page_objects/helioviewer.ts @@ -11,6 +11,7 @@ import { EventTree } from "./event_tree"; import { VSODrawer } from "./vso_drawer"; import { ScaleIndicator } from "./scale_indicator"; import * as fs from "fs"; +import { DesktopInterface } from "./helioviewer_interface"; /** * Matches an image layer selection @@ -22,7 +23,7 @@ interface LayerSelect { value: string; } -class Helioviewer { +class Helioviewer implements DesktopInterface { info: TestInfo | null; page: Page; sidebar: Locator; @@ -43,6 +44,21 @@ class Helioviewer { this.sidebar = this.page.locator("#hv-drawer-left"); } + /** + * Alias for CloseSidebar to support mobile tests. + */ + CloseDrawer(): Promise { + return this.CloseSidebar(); + } + + /** + * Alias for OpenSidebar, this is to be able to run mobile tests against + * Desktop. + */ + OpenImageLayerDrawer(): Promise { + return this.OpenSidebar(); + } + /** * Returns a handle to interact with event tree in UI * @param source string, ex: HEK, CCMC, RHESSI @@ -101,9 +117,33 @@ class Helioviewer { ]); } + /** + * Makes sure the given function is executed with the sidebar open. + * This retains the current state of the sidebar before/after executing + * function. + * + * If the sidebar is open, it will be open when fn is done. + * If the sidebar is closed, it will be opened before calling fn, and closed + * after calling fn. + * @param fn + */ + private async _WithSidebar(fn: () => any): Promise { + const sidebarWasClosed = await this.IsSidebarClosed(); + if (sidebarWasClosed) { + await this.OpenSidebar(); + } + const result = await fn(); + if (sidebarWasClosed) { + await this.CloseSidebar(); + } + return result; + } + async UseNewestImage() { - await this.page.getByText("NEWEST", { exact: true }).click(); - await this.page.waitForTimeout(500); + await this._WithSidebar(async () => { + await this.page.getByText("NEWEST", { exact: true }).click(); + await this.page.waitForTimeout(500); + }); } /** @@ -230,7 +270,7 @@ class Helioviewer { */ async OpenSidebar() { if (await this.IsSidebarClosed()) { - this.ClickDataSourcesTab(); + await this.ClickDataSourcesTab(); await expect(this.sidebar).toHaveAttribute("style", /^.*width: 27em.*$/); } } @@ -342,14 +382,13 @@ class Helioviewer { * @returns {Date} Loaded date of helioviewer, it can be null if any error. */ async GetLoadedDate(): Promise { - const currentDate = await this.page.getByLabel("Observation date", { exact: true }).inputValue(); - const currentTime = await this.page.getByLabel("Observation time", { exact: true }).inputValue(); - - const date = new Date(currentDate + " " + currentTime + "Z"); - - expect(date.getTime()).not.toBeNaN(); - - return date; + return await this._WithSidebar(async () => { + const currentDate = await this.page.getByLabel("Observation date", { exact: true }).inputValue(); + const currentTime = await this.page.getByRole("textbox", { name: "Observation time" }).inputValue(); + const date = new Date(currentDate + " " + currentTime + "Z"); + expect(date.getTime()).not.toBeNaN(); + return date; + }); } /** @@ -396,9 +435,10 @@ class Helioviewer { filename = filename + ".png"; } - // save file to info report - const filepath = this.info.outputPath(filename); - await fs.promises.writeFile(filepath, Buffer.from(base64Image, "base64")); + // save file to info report and snapshot path + const filepath = this.info.snapshotPath(filename); + fs.mkdirSync(this.info.snapshotDir, { recursive: true }); + fs.writeFileSync(filepath, Buffer.from(base64Image, "base64")); await this.info.attach(filename, { path: filepath }); // return the base64 screenshot diff --git a/tests/page_objects/helioviewer_embed.ts b/tests/page_objects/helioviewer_embed.ts index d6153aa54..64829643d 100644 --- a/tests/page_objects/helioviewer_embed.ts +++ b/tests/page_objects/helioviewer_embed.ts @@ -1,11 +1,11 @@ import { expect, Page, TestInfo } from "@playwright/test"; import { Helioviewer } from "./helioviewer"; -import { HelioviewerInterface } from "./helioviewer_interface"; +import { EmbedInterface } from "./helioviewer_interface"; /** * Interface for Helioviewer's Embedded view */ -class HelioviewerEmbed implements HelioviewerInterface { +class HelioviewerEmbed implements EmbedInterface { /** Playwright page */ page: Page; info: TestInfo | null; diff --git a/tests/page_objects/helioviewer_interface.ts b/tests/page_objects/helioviewer_interface.ts index 47e74875a..04066c63d 100644 --- a/tests/page_objects/helioviewer_interface.ts +++ b/tests/page_objects/helioviewer_interface.ts @@ -1,13 +1,15 @@ -import { Page } from "@playwright/test"; +import { Page, PageScreenshotOptions, TestInfo } from "@playwright/test"; import { Helioviewer } from "./helioviewer"; import { HelioviewerEmbed } from "./helioviewer_embed"; import { HelioviewerMinimal } from "./helioviewer_minimal"; +import { HvMobile } from "./mobile_hv"; +import { ImageLayer } from "./image_layer"; /** - * Represents the common functions that should be available in all Helioviewer - * interfaces (mobile, embed, minimal, desktop) + * Represents the common functions that should be available in the Embed view + * and above (Embed, Minimal, Mobile, and Desktop) */ -interface HelioviewerInterface { +interface EmbedInterface { /** * Loads the given page url. * This function should also wait until the HV application has finished loading. @@ -40,22 +42,105 @@ interface HelioviewerInterface { ZoomOut(steps: number): Promise; } -/** View types available for helioviewer */ -type HelioviewerView = "Normal" | "Minimal" | "Embed"; +/** + * Minimal view functionality. + * Supports all functions in EmbedView. + */ +interface MinimalInterface extends EmbedInterface {} + +/** + * Mobile specific functionality + * Supports all functions in Minimal and Embed + */ +interface MobileInterface extends MinimalInterface { + /** + * Opens the section of the UI which contains image layer information + */ + OpenImageLayerDrawer(): Promise; -// Add some constants for the strings so we don't need to deal with string -// literals. -const NormalView = "Normal"; -const MinimalView = "Minimal"; -const EmbedView = "Embed"; + /** + * Close any open drawer + */ + CloseDrawer(): Promise; + + /** + * Get a reference to an image layer's controls + */ + getImageLayer(index: number): Promise; + + /** + * Get the current observation date + * @returns {Date} Current Observation Date + */ + GetLoadedDate(): Promise; + + /** + * Sets observation datetime of Helioviewer from given Date object, + * @param {Date} Date The date object to be used to load observation datetime. + */ + SetObservationDateTimeFromDate(date: Date): Promise; + + /** + * Jump forward with jump button, with given seconds layer + * @param {number} seconds interval in seconds + * @returns {void} + */ + JumpForwardDateWithSelection(seconds: number): Promise; + + /** + * Attach base64 screnshot with a given filename to trace report + * also returns the screenshot string + * @param {string} filename name of file in trace report + * @param {PageScreenshotOptions} options pass options to playwright screenshot function + * @returns {Promise} base64 string screenshot + */ + saveScreenshot(filename?: string, options?: PageScreenshotOptions): Promise; + + /** + * Select option to use the newest image for the currently selected image layer + */ + UseNewestImage(): Promise; + + /** + * Jump backwards with jump button, with given seconds layer + * @param {number} seconds interval in seconds + * @returns {void} + */ + JumpBackwardsDateWithSelection(seconds: number): Promise; +} /** - * List of all available Helioviewer views. - * Iterate over this to create tests that apply to all views. + * Desktop specific functionality. + * Supports all functions in Mobile, Minimal, and Embed */ -let HelioviewerViews: HelioviewerView[] = [NormalView, MinimalView, EmbedView]; +interface DesktopInterface extends MobileInterface {} + +/** View types available for helioviewer */ +type HelioviewerView = { + name: string; + tag: string; +}; +const DesktopView: HelioviewerView = { + name: "Desktop", + tag: "@Desktop" +}; + +const MinimalView: HelioviewerView = { + name: "Minimal", + tag: "@Minimal" +}; + +const EmbedView: HelioviewerView = { + name: "Embed", + tag: "@Embed" +}; + +const MobileView: HelioviewerView = { + name: "Mobile", + tag: "@Mobile" +}; -class HelioviewerInterfaceFactory { +class HelioviewerFactory { /** * Returns an implementation for interacting with the desired helioviewer * interface. This is useful for writing one test case that applies to all views. @@ -65,18 +150,34 @@ class HelioviewerInterfaceFactory { * The scope of available functions is limited using the generic interface. * @param view */ - static Create(view: HelioviewerView, page: Page): HelioviewerInterface { + static Create( + view: HelioviewerView, + page: Page, + info: TestInfo + ): EmbedInterface | MinimalInterface | MobileInterface | DesktopInterface { switch (view) { case EmbedView: - return new HelioviewerEmbed(page); + return new HelioviewerEmbed(page, info); case MinimalView: - return new HelioviewerMinimal(page); - case NormalView: - return new Helioviewer(page); + return new HelioviewerMinimal(page, info); + case DesktopView: + return new Helioviewer(page, info); + case MobileView: + return new HvMobile(page, info); default: throw "Invalid View"; } } } -export { NormalView, MinimalView, EmbedView, HelioviewerViews, HelioviewerInterfaceFactory, HelioviewerInterface }; +export { + DesktopView, + MinimalView, + EmbedView, + MobileView, + EmbedInterface, + MinimalInterface, + MobileInterface, + DesktopInterface, + HelioviewerFactory +}; diff --git a/tests/page_objects/helioviewer_minimal.ts b/tests/page_objects/helioviewer_minimal.ts index 242ce06bf..1ab6a4a84 100644 --- a/tests/page_objects/helioviewer_minimal.ts +++ b/tests/page_objects/helioviewer_minimal.ts @@ -1,11 +1,11 @@ import { expect, Page, TestInfo } from "@playwright/test"; import { Helioviewer } from "./helioviewer"; -import { HelioviewerInterface } from "./helioviewer_interface"; +import { MinimalInterface } from "./helioviewer_interface"; /** * Interface for Helioviewer's Embedded view */ -class HelioviewerMinimal implements HelioviewerInterface { +class HelioviewerMinimal implements MinimalInterface { /** Playwright page */ private page: Page; private info: TestInfo | null; diff --git a/tests/page_objects/mobile_hv.ts b/tests/page_objects/mobile_hv.ts index 5b2ff9ac0..39e2290c8 100644 --- a/tests/page_objects/mobile_hv.ts +++ b/tests/page_objects/mobile_hv.ts @@ -6,12 +6,15 @@ import { Locator, Page, PageScreenshotOptions, TestInfo, expect } from "@playwri import { Helioviewer } from "./helioviewer"; import { ImageLayer } from "./image_layer"; import { ScaleIndicator } from "./scale_indicator"; +import { MobileInterface } from "./helioviewer_interface"; -class HvMobile { +class HvMobile implements MobileInterface { /** Helioviewer reference for shared interactions that apply to mobile and desktop */ private hv: Helioviewer; /** Playwright page object for interacting with the page */ private page: Page; + /** Bottom control bar locator */ + private _controls: Locator; /** #accordion-images - Reference to the image layer UI drawer */ private _image_drawer: Locator; /** [drawersec=accordion-images] - Ref to the button which opens the image drawer */ @@ -24,6 +27,7 @@ class HvMobile { constructor(page: Page, info: TestInfo | null = null) { this.page = page; this.hv = new Helioviewer(page, info); + this._controls = this.page.locator(".hvbottombar"); this._image_drawer = this.page.locator("#accordion-images"); this._image_drawer_btn = this.page.locator('[drawersec="accordion-images"]'); this._drawer = this.page.locator("#hv-drawer-left"); @@ -38,14 +42,15 @@ class HvMobile { * that the first image layer has been loaded. */ private async _WaitForInitialImageLayer() { - let layerAccordion = await this.page.locator("#tileLayerAccordion"); - let imageLayers = await layerAccordion.locator(".dynaccordion-section"); - await expect(imageLayers).toHaveCount(1); + let layerAccordion = this.page.locator("#tileLayerAccordion"); + let imageLayers = layerAccordion.locator(".dynaccordion-section"); + await expect(imageLayers).toHaveCount(1, { timeout: 30000 }); } /** Navigates to the mobile helioviewer page */ async Load() { await this.page.goto("/"); + await this.page.evaluate(() => console.log(localStorage.getItem("settings"))); // Wait for the first image layer to be loaded await this._WaitForInitialImageLayer(); @@ -69,10 +74,17 @@ class HvMobile { * @note Mobile doesn't have a loading spinner, so we can't easily wait for * all events to finish loading. */ - async WaitForLoad() { + async WaitForLoad(): Promise { await this.hv.WaitForImageLoad(); } + /** + * Alias for WaitForLoad to align with MobileInterface + */ + async WaitForLoadingComplete(): Promise { + return await this.WaitForLoad(); + } + /** * @returns true if the control drawer is open, else false. * On mobile, all the "accordions" are individual drawers, but they're @@ -283,6 +295,49 @@ class HvMobile { await this.page.locator("#hvmobscale_div #center-button").tap(); } + async GetLoadedDate(): Promise { + const currentDate = await this.page.getByLabel("Observation date", { exact: true }).inputValue(); + const currentTime = await this.page.getByRole("textbox", { name: "Observation time" }).inputValue(); + const date = new Date(currentDate + " " + currentTime + "Z"); + expect(date.getTime()).not.toBeNaN(); + return date; + } + + async SetObservationDateTime(date: string, time: string) { + await this._controls.getByLabel("Observation date", { exact: true }).click(); + await this._controls.getByLabel("Observation date", { exact: true }).fill(date); + await this._controls.getByLabel("Observation time").click(); + // On mobile, the flatpickr controls must be used for times. + const times = time.split(":"); + await this.page.locator(".flatpickr-calendar").getByLabel("Hour").click(); + await this.page.locator(".flatpickr-calendar").getByLabel("Hour").fill(times[0]); + await this.page.locator(".flatpickr-calendar").getByLabel("Minute").click(); + await this.page.locator(".flatpickr-calendar").getByLabel("Minute").fill(times[1]); + await this.page.locator(".flatpickr-calendar").getByLabel("Second").click(); + await this.page.locator(".flatpickr-calendar").getByLabel("Second").fill(times[2]); + await this.page.locator(".flatpickr-calendar").getByLabel("Second").press("Enter"); + } + + async SetObservationDateTimeFromDate(date: Date): Promise { + const dateParts = date.toISOString().split("T")[0].split("-"); + const dateString = `${dateParts[0]}/${dateParts[1]}/${dateParts[2]}`; + + const timeParts = date.toISOString().split("T")[1].split(":"); + const timeSeconds = timeParts[2].split(".")[0]; + const timeString = `${timeParts[0]}:${timeParts[1]}:${timeSeconds}`; + await this.SetObservationDateTime(dateString, timeString); + } + + async JumpForwardDateWithSelection(seconds: number): Promise { + await this._controls.getByLabel("Jump:").selectOption(seconds.toString()); + await this._controls.getByAltText("Timeframe right arrow").click(); + } + + async JumpBackwardsDateWithSelection(seconds: number): Promise { + await this._controls.getByLabel("Jump:").selectOption(seconds.toString()); + await this._controls.getByAltText("Timeframe left arrow").click(); + } + /** * Attach base64 screnshot with a given filename to trace report * also returns the screenshot string