diff --git a/app/client/lib/timeUtils.ts b/app/client/lib/timeUtils.ts index 637b2a97..3bc3fa85 100644 --- a/app/client/lib/timeUtils.ts +++ b/app/client/lib/timeUtils.ts @@ -4,8 +4,14 @@ import moment from 'moment'; * Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly * relative time to now - e.g. 'yesterday', '2 days ago'. */ -export function getTimeFromNow(utcDateISO: string): string { - const time = moment.utc(utcDateISO); +export function getTimeFromNow(utcDateISO: string): string +/** + * Given a unix timestamp (in milliseconds), gives a reader-friendly + * relative time to now - e.g. 'yesterday', '2 days ago'. + */ +export function getTimeFromNow(ms: number): string +export function getTimeFromNow(isoOrTimestamp: string|number): string { + const time = moment.utc(isoOrTimestamp); const now = moment(); const diff = now.diff(time, 's'); if (diff < 0 && diff > -60) { diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts index a8774233..da2c4393 100644 --- a/app/client/ui/AdminPanel.ts +++ b/app/client/ui/AdminPanel.ts @@ -1,9 +1,8 @@ -import {getPageTitleSuffix} from 'app/common/gristUrls'; -import {getGristConfig} from 'app/common/urlUtils'; -import * as version from 'app/common/version'; import {buildHomeBanners} from 'app/client/components/Banners'; import {makeT} from 'app/client/lib/localization'; -import {AppModel} from 'app/client/models/AppModel'; +import {localStorageJsonObs} from 'app/client/lib/localStorageObs'; +import {getTimeFromNow} from 'app/client/lib/timeUtils'; +import {AppModel, getHomeUrl} from 'app/client/models/AppModel'; import {urlState} from 'app/client/models/gristUrlState'; import {AppHeader} from 'app/client/ui/AppHeader'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; @@ -12,10 +11,19 @@ import {SupportGristPage} from 'app/client/ui/SupportGristPage'; import {createTopBarHome} from 'app/client/ui/TopBar'; import {transition} from 'app/client/ui/transitions'; import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs'; +import {basicButton} from 'app/client/ui2018/buttons'; +import {toggle} from 'app/client/ui2018/checkbox'; import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; -import {cssLink} from 'app/client/ui2018/links'; -import {Disposable, dom, DomContents, IDisposableOwner, Observable, styled} from 'grainjs'; +import {cssLink, makeLinks} from 'app/client/ui2018/links'; +import {getPageTitleSuffix} from 'app/common/gristUrls'; +import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI'; +import {naturalCompare} from 'app/common/SortFunc'; +import {getGristConfig} from 'app/common/urlUtils'; +import * as version from 'app/common/version'; +import {Computed, Disposable, dom, DomContents, IDisposable, + IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs'; + const t = makeT('AdminPanel'); @@ -26,6 +34,7 @@ export function getAdminPanelName() { export class AdminPanel extends Disposable { private _supportGrist = SupportGristPage.create(this, this._appModel); + private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl()); constructor(private _appModel: AppModel) { super(); @@ -62,7 +71,7 @@ export class AdminPanel extends Disposable { ); } - private _buildMainContent(owner: IDisposableOwner) { + private _buildMainContent(owner: MultiHolder) { return cssPageContainer( dom.cls('clipboard'), {tabIndex: "-1"}, @@ -91,6 +100,7 @@ export class AdminPanel extends Disposable { description: t('Current version of Grist'), value: cssValueLabel(`Version ${version.version}`), }), + this._buildUpdates(owner), ), testId('admin-panel'), ); @@ -138,18 +148,229 @@ export class AdminPanel extends Disposable { ); } } + + private _buildUpdates(owner: MultiHolder) { + // We can be in those states: + enum State { + // Never checked before (no last version or last check time). + // Shows "No information available" [Check now] + NEVER, + // Did check previously, but it was a while ago, user should press the button to check. + // Shows "Last checked X days ago" [Check now] + STALE, + // In the middle of checking for updates. + CHECKING, + // Transient state, shown after Check now is clicked. + // Grist is up to date (state only shown after a successful check), or even upfront. + // Won't be shown after page is reloaded. + // Shows "Checking for updates..." + CURRENT, + // A newer version is available. Can be shown after reload if last + // version that was checked is newer than the current version. + // Shows "Newer version available" [version] + AVAILABLE, + // Error occurred during this check. If the error occurred during last check + // it is not stored. + // Shows "Error checking for updates" [Check now] + ERROR, + } + + // Are updates enabled at all. + const defaultValue = { + onLoad: false, + lastCheckDate: null as number|null, + lastVersion: null as string|null, + }; + const prop = (key: T) => { + const computed = Computed.create(owner, (use) => use(settings)[key]); + computed.onWrite((val) => settings.set({...settings.get(), [key]: val})); + return computed as Observable; + }; + const settings = owner.autoDispose(localStorageJsonObs('new-version-check', defaultValue)); + const onLoad = prop('onLoad'); + const latestVersion = prop('lastVersion'); + const lastCheckDate = prop('lastCheckDate'); + const comparison = Computed.create(owner, (use) => { + const versions = [version.version, use(latestVersion)]; + if (!versions[1]) { + return null; + } + // Sort them in natural order, so that "1.10" comes after "1.9". + versions.sort(naturalCompare).reverse(); + if (versions[0] === version.version) { + return 'old'; + } else { + return 'new'; + } + }); + + // Observable state of the updates check. + const state: Observable = Observable.create(owner, State.NEVER); + + // The background task that checks for updates, can be disposed (cancelled) when needed. + let backgroundTask: IDisposable|null = null; + + // By default we link to the GitHub releases page, but the endpoint might say something different. + let releaseURL = 'https://github.com/gristlabs/grist-core/releases'; + + // All the events that might occur + const actions = { + checkForUpdates: async () => { + state.set(State.CHECKING); + latestVersion.set(null); + // We can be disabled, why the check is in progress. + const controller = new AbortController(); + backgroundTask = { + dispose() { + if (controller.signal.aborted) { return; } + backgroundTask = null; + controller.abort(); + } + }; + owner.autoDispose(backgroundTask); + try { + const result = await this._installAPI.checkUpdates(); + if (controller.signal.aborted) { return; } + actions.gotLatestVersion(result); + } catch(err) { + if (controller.signal.aborted) { return; } + state.set(State.ERROR); + reportError(err); + } + }, + disableAutoCheck: () => { + backgroundTask?.dispose(); + backgroundTask = null; + onLoad.set(false); + }, + enableAutoCheck: () => { + onLoad.set(true); + if (state.get() !== State.CHECKING && state.get() !== State.AVAILABLE) { + actions.checkForUpdates().catch(reportError); + } + }, + gotLatestVersion: (data: LatestVersion) => { + lastCheckDate.set(Date.now()); + latestVersion.set(data.latestVersion); + releaseURL = data.updateURL || releaseURL; + const result = comparison.get(); + switch (result) { + case 'old': state.set(State.CURRENT); break; + case 'new': state.set(State.AVAILABLE); break; + // This should not happen, but if it does, we should show the error. + default: state.set(State.ERROR); break; + } + } + }; + + const description = Computed.create(owner, (use) => { + switch (use(state)) { + case State.NEVER: return t('No information available'); + case State.CHECKING: return '⌛ ' + t('Checking for updates...'); + case State.CURRENT: return '✅ ' + t('Grist is up to date'); + case State.AVAILABLE: return t('Newer version available'); + case State.ERROR: return '❌ ' + t('Error checking for updates'); + case State.STALE: { + const lastCheck = use(lastCheckDate); + return t('Last checked {{time}}', {time: lastCheck ? getTimeFromNow(lastCheck) : 'n/a'}); + } + } + }); + + // Now trigger the initial state, by checking if we should auto-check. + if (onLoad.get()) { + actions.checkForUpdates().catch(reportError); + } else { + if (comparison.get() === 'new') { + state.set(State.AVAILABLE); + } else if (comparison.get() === 'old') { + state.set(State.STALE); + } else { + state.set(State.NEVER); // default one. + } + } + + // Toggle component operates on a boolean observable, without a way to set the value. So + // create a controller for it to intercept the write and call the appropriate action. + const enabledController = Computed.create(owner, (use) => use(onLoad)); + enabledController.onWrite((val) => { + if (val) { + actions.enableAutoCheck(); + } else { + actions.disableAutoCheck(); + } + }); + + const upperCheckNowVisible = Computed.create(owner, (use) => { + switch (use(state)) { + case State.CHECKING: + case State.CURRENT: + case State.AVAILABLE: + return false; + default: + return true; + } + }); + + return this._buildItem(owner, { + id: 'updates', + name: t('Updates'), + description: dom('span', testId('admin-panel-updates-message'), dom.text(description)), + value: cssValueButton( + dom.domComputed(use => { + if (use(state) === State.CHECKING) { + return null; + } + + if (use(upperCheckNowVisible)) { + return basicButton( + t('Check now'), + dom.on('click', actions.checkForUpdates), + testId('admin-panel-updates-upper-check-now') + ); + } + + if (use(latestVersion)) { + return cssValueLabel(`Version ${use(latestVersion)}`, testId('admin-panel-updates-version')); + } + + throw new Error('Invalid state'); + }) + ), + expandedContent: cssColumns( + cssColumn( + cssColumn.cls('-left'), + dom('div', t('Grist releases are at '), makeLinks(releaseURL)), + dom.maybe(lastCheckDate, ms => dom('div', + dom('span', t('Last checked {{time}}', {time: getTimeFromNow(ms)})), + dom('span', ' '), + // Format date in local format. + cssGrayed(new Date(ms).toLocaleString()), + )), + dom('div', t('Auto-check when this page loads')), + ), + cssColumn( + cssColumn.cls('-right'), + // `Check now` button, only shown when auto checks are enabled and we are not in the + // middle of checking. Otherwise the button is shown in the summary row, and there is + // no need to duplicate it. + dom.maybe(use => !use(upperCheckNowVisible), () => [ + cssCheckNowButton( + t('Check now'), + testId('admin-panel-updates-lower-check-now'), + dom.on('click', actions.checkForUpdates), + dom.prop('disabled', use => use(state) === State.CHECKING), + ), + ]), + toggle(enabledController, testId('admin-panel-updates-auto-check')), + ), + ) + }); + } } function maybeSwitchToggle(value: Observable): DomContents { - return dom('div.widget_switch', - (elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()), - dom.hide((use) => use(value) === null), - dom.cls('switch_on', (use) => use(value) || false), - dom.cls('switch_transition', true), - dom.on('click', () => value.set(!value.get())), - dom('div.switch_slider'), - dom('div.switch_circle'), - ); + return toggle(value, dom.hide((use) => use(value) === null)); } const cssPageContainer = styled('div', ` @@ -215,7 +436,7 @@ const cssItemName = styled('div', ` font-weight: bold; font-size: ${vars.largeFontSize}; &:first-child { - margin-left: 28px; + margin-left: 24px; } @media ${mediaSmall} { & { @@ -267,4 +488,52 @@ const cssValueLabel = styled('div', ` color: ${theme.text}; border: 1px solid ${theme.inputBorder}; border-radius: ${vars.controlBorderRadius}; + &-empty { + visibility: hidden; + content: " "; + } +`); + +// A wrapper for the version details panel. Shows two columns. +// First grows as needed, second shrinks as needed and is aligned to the bottom. +const cssColumns = styled('div', ` + display: flex; + align-items: flex-end; + & > div:first-child { + flex-grow: 1; + flex-shrink: 0; + } + & > div:last-child { + flex-shrink: 1; + } +`); + +const cssColumn = styled('div', ` + display: flex; + flex-direction: column; + gap: 8px; + flex-grow: 1; + flex-shrink: 1; + margin-block: 1px; /* otherwise toggle is squashed: TODO: -1px in toggle looks like a bug */ + &-left { + align-items: flex-start; + } + &-right { + align-items: flex-end; + justify-content: flex-end; + } +`); + +const cssValueButton = styled('div', ` + height: 30px; +`); + +const cssCheckNowButton = styled(basicButton, ` + &-hidden { + visibility: hidden; + } +`); + +const cssGrayed = styled('span', ` + color: ${theme.lightText}; `); diff --git a/app/client/ui2018/checkbox.ts b/app/client/ui2018/checkbox.ts index 5ede5ef9..239989bf 100644 --- a/app/client/ui2018/checkbox.ts +++ b/app/client/ui2018/checkbox.ts @@ -16,7 +16,7 @@ */ import {testId, theme} from 'app/client/ui2018/cssVars'; -import {Computed, dom, DomArg, DomContents, Observable, styled} from 'grainjs'; +import {Computed, dom, DomArg, DomContents, DomElementArg, Observable, styled} from 'grainjs'; export const cssLabel = styled('label', ` position: relative; @@ -194,6 +194,19 @@ export const cssRadioCheckboxOptions = styled('div', ` gap: 10px; `); +export function toggle(value: Observable, ...domArgs: DomElementArg[]): DomContents { + return dom('div.widget_switch', + (elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()), + dom.hide((use) => use(value) === null), + dom.cls('switch_on', (use) => use(value) || false), + dom.cls('switch_transition', true), + dom.on('click', () => value.set(!value.get())), + dom('div.switch_slider'), + dom('div.switch_circle'), + ...domArgs + ); +} + // We need to reset top and left of ::before element, as it is wrongly set // on the inline checkbox. // To simulate radio button behavior, we will block user input after option is selected, because diff --git a/app/common/InstallAPI.ts b/app/common/InstallAPI.ts index 745b0c1e..88cc35a5 100644 --- a/app/common/InstallAPI.ts +++ b/app/common/InstallAPI.ts @@ -24,9 +24,38 @@ export interface PrefWithSource { export type PrefSource = 'environment-variable' | 'preferences'; + +/** + * JSON returned to the client (exported for tests). + */ +export interface LatestVersion { + /** + * Latest version of core component of the client. + */ + latestVersion: string; + /** + * If there were any critical updates after client's version. Undefined if + * we don't know client version or couldn't figure this out for some other reason. + */ + isCritical?: boolean; + /** + * Url where the client can download the latest version (if applicable) + */ + updateURL?: string; + + /** + * When the latest version was updated (in ISO format). + */ + updatedAt?: string; +} + export interface InstallAPI { getInstallPrefs(): Promise; updateInstallPrefs(prefs: Partial): Promise; + /** + * Returns information about latest version of Grist + */ + checkUpdates(): Promise; } export class InstallAPIImpl extends BaseAPI implements InstallAPI { @@ -45,6 +74,10 @@ export class InstallAPIImpl extends BaseAPI implements InstallAPI { }); } + public checkUpdates(): Promise { + return this.requestJson(`${this._url}/api/install/updates`, {method: 'GET'}); + } + private get _url(): string { return addCurrentOrgToPath(this._homeUrl); } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 903c3206..b852c853 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -105,6 +105,8 @@ export const commonUrls = { gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json', githubGristCore: 'https://github.com/gristlabs/grist-core', githubSponsorGristLabs: 'https://github.com/sponsors/gristlabs', + + versionCheck: 'https://api.getgrist.com/api/version', }; /** diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index d3d51231..5c10a65c 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -2,7 +2,7 @@ import {ApiError} from 'app/common/ApiError'; import {ICustomWidget} from 'app/common/CustomWidget'; import {delay} from 'app/common/delay'; import {DocCreationInfo} from 'app/common/DocListAPI'; -import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, +import {commonUrls, encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes, GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls'; import {getOrgUrlInfo} from 'app/common/gristUrls'; @@ -1853,6 +1853,35 @@ export class FlexServer implements GristServer { return resp.status(200).send(); })); + + // GET api/checkUpdates + // Retrieves the latest version of the client from Grist SAAS endpoint. + this.app.get('/api/install/updates', adminPageMiddleware, expressWrap(async (req, res) => { + // Prepare data for the telemetry that endpoint might expect. + const installationId = (await this.getActivations().current()).id; + const deploymentType = this.getDeploymentType(); + const currentVersion = version.version; + const response = await fetch(process.env.GRIST_TEST_VERSION_CHECK_URL || commonUrls.versionCheck, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + installationId, + deploymentType, + currentVersion, + }), + }); + if (!response.ok) { + res.status(response.status); + if (response.headers.get('content-type')?.includes('application/json')) { + const data = await response.json(); + res.json(data); + } else { + res.send(await response.text()); + } + } else { + res.json(await response.json()); + } + })); } // Get the HTML template sent for document pages. diff --git a/app/server/lib/UpdateManager.ts b/app/server/lib/UpdateManager.ts index d96b7090..47556b74 100644 --- a/app/server/lib/UpdateManager.ts +++ b/app/server/lib/UpdateManager.ts @@ -1,14 +1,16 @@ import { ApiError } from "app/common/ApiError"; import { MapWithTTL } from "app/common/AsyncCreate"; import { GristDeploymentType } from "app/common/gristUrls"; +import { LatestVersion } from 'app/common/InstallAPI'; import { naturalCompare } from "app/common/SortFunc"; import { RequestWithLogin } from "app/server/lib/Authorizer"; +import { expressWrap } from 'app/server/lib/expressWrap'; import { GristServer } from "app/server/lib/GristServer"; import { optIntegerParam, optStringParam } from "app/server/lib/requestUtils"; +import { rateLimit } from 'express-rate-limit'; import { AbortController, AbortSignal } from 'node-abort-controller'; import type * as express from "express"; import fetch from "node-fetch"; -import {expressWrap} from 'app/server/lib/expressWrap'; // URL to show to the client where the new version for docker based deployments can be found. @@ -67,8 +69,18 @@ export class UpdateManager { } } + // Rate limit the requests to the version API, so that we don't get spammed. + // 30 requests per second, per IP. The requests are cached so, we should be fine, but make + // sure it doesn't get out of hand. On dev laptop I could go up to 600 requests per second. + // (30 was picked by hand, to not hit the limit during tests). + const limiter = rateLimit({ + windowMs: 1000, + limit: 30, + legacyHeaders: true, + }); + // Support both POST and GET requests. - this._app.use("/api/version", expressWrap(async (req, res) => { + this._app.use("/api/version", limiter, expressWrap(async (req, res) => { // Get some telemetry from the body request. const payload = (name: string) => req.body?.[name] ?? req.query[name]; @@ -132,29 +144,6 @@ export class UpdateManager { } } -/** - * JSON returned to the client (exported for tests). - */ -export interface LatestVersion { - /** - * Latest version of core component of the client. - */ - latestVersion: string; - /** - * If there were any critical updates after client's version. Undefined if - * we don't know client version or couldn't figure this out for some other reason. - */ - isCritical?: boolean; - /** - * Url where the client can download the latest version (if applicable) - */ - updateURL?: string; - - /** - * When the latest version was updated (in ISO format). - */ - updatedAt?: string; -} type VersionChecker = (signal: AbortSignal) => Promise; diff --git a/test/gen-server/UpdateChecks.ts b/test/gen-server/UpdateChecks.ts index 3633a401..cdd9426f 100644 --- a/test/gen-server/UpdateChecks.ts +++ b/test/gen-server/UpdateChecks.ts @@ -4,10 +4,11 @@ import * as sinon from 'sinon'; import { configForUser } from "test/gen-server/testUtils"; import * as testUtils from "test/server/testUtils"; -import { serveSomething, Serving } from "test/server/customUtil"; -import { Deps, LatestVersion } from "app/server/lib/UpdateManager"; +import { Defer, serveSomething, Serving } from "test/server/customUtil"; +import { Deps } from "app/server/lib/UpdateManager"; import { TestServer } from "test/gen-server/apiUtils"; import { delay } from "app/common/delay"; +import { LatestVersion } from 'app/common/InstallAPI'; const assert = chai.assert; @@ -249,7 +250,7 @@ async function dummyDockerHub() { return Object.assign(tempServer, { signal() { - const p = defer(); + const p = new Defer(); signals.push(p); return p; }, @@ -326,22 +327,3 @@ const FIRST_PAGE = (tempServer: Serving) => ({ next: tempServer.url + "/next", }); -interface Defer { - then: Promise["then"]; - resolve: () => void; - reject: () => void; -} - -const defer = () => { - let resolve: () => void; - let reject: () => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }).catch(() => {}); - return { - then: promise.then.bind(promise), - resolve: resolve!, - reject: reject!, - }; -}; diff --git a/test/nbrowser/AdminPanel.ts b/test/nbrowser/AdminPanel.ts index fd92ff68..209baf8c 100644 --- a/test/nbrowser/AdminPanel.ts +++ b/test/nbrowser/AdminPanel.ts @@ -2,14 +2,17 @@ import {TelemetryLevel} from 'app/common/Telemetry'; import {assert, driver, Key, WebElement} from 'mocha-webdriver'; import * as gu from 'test/nbrowser/gristUtils'; import {server, setupTestSuite} from 'test/nbrowser/testUtils'; +import {Defer, serveSomething, Serving} from 'test/server/customUtil'; import * as testUtils from 'test/server/testUtils'; +import express from 'express'; describe('AdminPanel', function() { - this.timeout(30000); + this.timeout(300000); setupTestSuite(); let oldEnv: testUtils.EnvironmentSnapshot; let session: gu.Session; + let fakeServer: FakeUpdateServer; afterEach(() => gu.checkForErrors()); @@ -17,10 +20,13 @@ describe('AdminPanel', function() { oldEnv = new testUtils.EnvironmentSnapshot(); process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core'; process.env.GRIST_DEFAULT_EMAIL = gu.session().email; + fakeServer = await startFakeServer(); + process.env.GRIST_TEST_VERSION_CHECK_URL = `${fakeServer.url()}/version`; await server.restart(true); }); after(async function() { + await fakeServer.close(); oldEnv.restore(); await server.restart(true); }); @@ -170,6 +176,128 @@ describe('AdminPanel', function() { assert.equal(await driver.find('.test-admin-panel-item-version').isDisplayed(), true); assert.match(await driver.find('.test-admin-panel-item-value-version').getText(), /^Version \d+\./); }); + + const upperCheckNow = () => driver.find('.test-admin-panel-updates-upper-check-now'); + const lowerCheckNow = () => driver.find('.test-admin-panel-updates-lower-check-now'); + const autoCheckToggle = () => driver.find('.test-admin-panel-updates-auto-check'); + const updateMessage = () => driver.find('.test-admin-panel-updates-message'); + const versionBox = () => driver.find('.test-admin-panel-updates-version'); + function waitForStatus(message: RegExp) { + return gu.waitToPass(async () => { + assert.match(await updateMessage().getText(), message); + }); + } + + it('should check for updates', async function() { + // Clear any cached settings. + await driver.executeScript('window.sessionStorage.clear(); window.localStorage.clear();'); + await driver.navigate().refresh(); + await waitForAdminPanel(); + + // By default don't have any info. + await waitForStatus(/No information available/); + + // We see upper check-now button. + assert.isTrue(await upperCheckNow().isDisplayed()); + + // We can expand. + await toggleItem('updates'); + + // We see a toggle to update automatically. + assert.isTrue(await autoCheckToggle().isDisplayed()); + assert.isFalse(await isSwitchOn(autoCheckToggle())); + + // We can click it, Grist will turn on auto checks and do it right away. + fakeServer.pause(); + await autoCheckToggle().click(); + assert.isTrue(await isSwitchOn(autoCheckToggle())); + + // It will first show "Checking for updates" message. + // (Request is blocked by fake server, so it will not complete until we resume it.) + await waitForStatus(/Checking for updates/); + + // Upper check now button is removed. + assert.isFalse(await upperCheckNow().isPresent()); + + // Resume server and respond. + fakeServer.resume(); + + // It will show "New version available" message. + await waitForStatus(/Newer version available/); + // And a version number. + assert.isTrue(await versionBox().isDisplayed()); + assert.match(await versionBox().getText(), /Version 9\.9\.9/); + + // When we reload, we will auto check for updates. + fakeServer.pause(); + fakeServer.latestVersion = await currentVersion(); + await driver.navigate().refresh(); + await waitForAdminPanel(); + await waitForStatus(/Checking for updates/); + fakeServer.resume(); + await waitForStatus(/Grist is up to date/); + + // Disable auto-checks. + await toggleItem('updates'); + assert.isTrue(await isSwitchOn(autoCheckToggle())); + await autoCheckToggle().click(); + assert.isFalse(await isSwitchOn(autoCheckToggle())); + // Nothing should happen. + await waitForStatus(/Grist is up to date/); + assert.isTrue(await versionBox().isDisplayed()); + assert.equal(await versionBox().getText(), `Version ${await currentVersion()}`); + + // Refresh to see if we are disabled. + fakeServer.pause(); + await driver.navigate().refresh(); + await waitForAdminPanel(); + await waitForStatus(/Last checked .+ ago/); + fakeServer.resume(); + // Expand and see if the toggle is off. + await toggleItem('updates'); + assert.isFalse(await isSwitchOn(autoCheckToggle())); + }); + + it('shows up-to-date message', async function() { + fakeServer.latestVersion = await currentVersion(); + // Click upper check now. + await waitForStatus(/Last checked .+ ago/); + await upperCheckNow().click(); + await waitForStatus(/Grist is up to date/); + + // Update version once again. + fakeServer.latestVersion = '9.9.10'; + // Click lower check now. + fakeServer.pause(); + await lowerCheckNow().click(); + await waitForStatus(/Checking for updates/); + fakeServer.resume(); + await waitForStatus(/Newer version available/); + + // Make sure we see the new version. + assert.isTrue(await versionBox().isDisplayed()); + assert.match(await versionBox().getText(), /Version 9\.9\.10/); + }); + + it('shows error message', async function() { + fakeServer.failNext = true; + fakeServer.pause(); + await lowerCheckNow().click(); + await waitForStatus(/Checking for updates/); + fakeServer.resume(); + await waitForStatus(/Error checking for updates/); + assert.match((await gu.getToasts())[0], /some error/); + await gu.wipeToasts(); + }); + + it('should send telemetry data', async function() { + assert.deepEqual({...fakeServer.payload, installationId: 'test'}, { + installationId: 'test', + deploymentType: 'core', + currentVersion: await currentVersion(), + }); + assert.isNotEmpty(fakeServer.payload.installationId); + }); }); async function assertTelemetryLevel(level: TelemetryLevel) { @@ -193,3 +321,64 @@ async function withExpandedItem(itemId: string, callback: () => Promise) { const isSwitchOn = (switchElem: WebElement) => switchElem.matches('[class*=switch_on]'); const waitForAdminPanel = () => driver.findWait('.test-admin-panel', 2000); + +interface FakeUpdateServer { + latestVersion: string; + failNext: boolean; + payload: any; + close: () => Promise; + pause: () => void; + resume: () => void; + url: () => string; +} + +async function startFakeServer() { + let mutex: Defer|null = null; + const API: FakeUpdateServer = { + latestVersion: '9.9.9', + failNext: false, + payload: null, + close: async () => { + mutex?.resolve(); + mutex = null; + await server?.shutdown(); + server = null; + }, + pause: () => { + mutex = new Defer(); + }, + resume: () => { + mutex?.resolve(); + mutex = null; + }, + url: () => { + return server!.url; + } + }; + + let server: Serving|null = await serveSomething((app) => { + app.use(express.json()); + app.post('/version', async (req, res, next) => { + API.payload = req.body; + try { + await mutex; + if (API.failNext) { + res.status(500).json({error: 'some error'}); + API.failNext = false; + return; + } + res.json({latestVersion: API.latestVersion}); + } catch(ex) { + next(ex); + } + }); + }); + + return API; +} + +async function currentVersion() { + const currentVersionText = await driver.find(".test-admin-panel-item-value-version").getText(); + const currentVersion = currentVersionText.match(/Version (.+)/)![1]; + return currentVersion; +} diff --git a/test/server/customUtil.ts b/test/server/customUtil.ts index c2646573..efd727bf 100644 --- a/test/server/customUtil.ts +++ b/test/server/customUtil.ts @@ -64,3 +64,31 @@ export async function serveSomething(setup: (app: express.Express) => void, port const url = `http://localhost:${port}`; return {url, shutdown}; } + +/** + * Creates a promise like object that can be resolved from outside. + */ +export class Defer { + private _resolve!: () => void; + private _reject!: (err: any) => void; + private _promise: Promise; + + constructor() { + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + public get then() { + return this._promise.then.bind(this._promise); + } + + public resolve() { + this._resolve(); + } + + public reject(err: any) { + this._reject(err); + } +}