Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions common/config/src/main/resources/gui.conf
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,9 @@ gui {
# whether to show the "Powered by Texera" attribution link in the sidebar
attribution-enabled = false
attribution-enabled = ${?GUI_ATTRIBUTION_ENABLED}

# whether to poll for new deployments and prompt the user to reload when a
# newer build is detected. Off by default; enable per-deployment.
deployment-version-check-enabled = false
deployment-version-check-enabled = ${?GUI_DEPLOYMENT_VERSION_CHECK_ENABLED}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ object GuiConfig {
conf.getInt("gui.workflow-workspace.limit-columns")
val guiAttributionEnabled: Boolean =
conf.getBoolean("gui.attribution-enabled")
val guiDeploymentVersionCheckEnabled: Boolean =
conf.getBoolean("gui.deployment-version-check-enabled")
val guiWorkflowWorkspacePythonNotebookMigrationEnabled: Boolean =
conf.getBoolean("gui.workflow-workspace.python-notebook-migration-enabled")
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class ConfigResource {
"password" -> GuiConfig.guiLoginDefaultLocalUserPassword
),
"attributionEnabled" -> GuiConfig.guiAttributionEnabled,
"deploymentVersionCheckEnabled" -> GuiConfig.guiDeploymentVersionCheckEnabled,
"inviteOnly" -> UserSystemConfig.inviteOnly
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class ConfigResourceAuthSpec extends AnyFlatSpec with Matchers with BeforeAndAft
"googleLogin",
"defaultLocalUser",
"attributionEnabled",
"deploymentVersionCheckEnabled",
"inviteOnly"
)
}
Expand Down
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@

# Generated by build-version.js on prod builds.
/src/environments/version.prod.ts
/src/assets/version.json
24 changes: 24 additions & 0 deletions frontend/build-version.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

// Types for build-version.js.
export function renderVersionArtifacts(
version: string,
buildNumber?: string
): { buildNumber: string; prodTs: string; manifestJson: string };
31 changes: 22 additions & 9 deletions frontend/build-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,32 @@
// Dev builds (`yarn start`) keep the static "dev" string in version.ts.

const { generate } = require("build-number-generator");
const { version } = require("./package.json");
const { resolve } = require("path");
const { writeFileSync } = require("fs");

const buildNumber = generate(version);
const out = resolve(__dirname, "src", "environments", "version.prod.ts");
writeFileSync(
out,
`// AUTO-GENERATED by build-version.js — do not edit or commit.
// Returns the contents of version.prod.ts and version.json for a version.
function renderVersionArtifacts(version, buildNumber = generate(version)) {
const prodTs = `// AUTO-GENERATED by build-version.js — do not edit or commit.
export const Version = {
buildNumber: ${JSON.stringify(buildNumber)},
version: ${JSON.stringify(version)},
};
`,
);
console.log(`build-version: ${buildNumber}`);
`;
const manifestJson = JSON.stringify({ buildNumber, version }) + "\n";
return { buildNumber, prodTs, manifestJson };
}

function main() {
const { version } = require("./package.json");
const { buildNumber, prodTs, manifestJson } = renderVersionArtifacts(version);
writeFileSync(resolve(__dirname, "src", "environments", "version.prod.ts"), prodTs);
writeFileSync(resolve(__dirname, "src", "assets", "version.json"), manifestJson);
console.log(`build-version: ${buildNumber}`);
}
Comment on lines +41 to +47

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it the only way to write a file? how about use an env variable?

either way we have to take care of the file life cycle: who is going to delete this file/env variable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An env var won't work: the browser fetches version.json at runtime to read the deployed version, and it can't read a server-side env var. Both generated files are gitignored and rewritten in place every build, so there's nothing to clean up.


module.exports = { renderVersionArtifacts };

// Write files only when run directly, not when required by a test.
if (require.main === module) {
main();
}
159 changes: 159 additions & 0 deletions frontend/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { HttpClientTestingModule } from "@angular/common/http/testing";
import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterTestingModule } from "@angular/router/testing";
import { AppComponent } from "./app.component";
import { GuiConfigService } from "./common/service/gui-config.service";
import { DeploymentVersionService } from "./common/service/deployment-version/deployment-version.service";
import { NotificationService } from "./common/service/notification/notification.service";
import { Version } from "../environments/version";

// GuiConfigService stub whose env getter either returns a value or throws,
// mirroring "config loaded" vs "config failed to load by APP_INITIALIZER".
class StubGuiConfigService {
shouldThrow = false;
deploymentVersionCheckEnabled = true;
get env(): unknown {
if (this.shouldThrow) {
throw new Error("config not loaded");
}
return { deploymentVersionCheckEnabled: this.deploymentVersionCheckEnabled };
}
}

describe("AppComponent", () => {
let config: StubGuiConfigService;
// The real DeploymentVersionService, with its polling entry point spied so
// the test asserts on the wiring without kicking off real HTTP polling.
let startPollingSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
Version.buildNumber = "dev";
config = new StubGuiConfigService();

TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule, HttpClientTestingModule],
declarations: [AppComponent],
providers: [
{ provide: GuiConfigService, useValue: config },
DeploymentVersionService,
// NotificationService is a transitive dependency of DeploymentVersionService.
{ provide: NotificationService, useValue: { blank: vi.fn() } },
],
});
const deploymentVersionService = TestBed.inject(DeploymentVersionService);
startPollingSpy = vi
.spyOn(deploymentVersionService, "startPollingForUpdates")
.mockReturnValue({ unsubscribe: () => undefined } as never);
});

// Version is a shared module singleton; restore the dev default so a test
// that flips buildNumber cannot leak into other suites in the same worker.
afterEach(() => {
Version.buildNumber = "dev";
});

function create(): ComponentFixture<AppComponent> {
return TestBed.createComponent(AppComponent);
}

describe("config-loaded detection", () => {
it("marks config as loaded when env is accessible", () => {
config.shouldThrow = false;
const component = create().componentInstance;
expect(component.configLoaded).toBe(true);
});

it("marks config as not loaded when accessing env throws", () => {
config.shouldThrow = true;
const component = create().componentInstance;
expect(component.configLoaded).toBe(false);
});

it("renders the configuration-error panel when config is not loaded", () => {
config.shouldThrow = true;
const fixture = create();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector("#config-error")).not.toBeNull();
});

it("does not render the configuration-error panel when config is loaded", () => {
config.shouldThrow = false;
const fixture = create();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector("#config-error")).toBeNull();
});
});

describe("deployment-version polling guard", () => {
it("does not start polling for the 'dev' placeholder build", () => {
config.deploymentVersionCheckEnabled = true;
Version.buildNumber = "dev";
create();
expect(startPollingSpy).not.toHaveBeenCalled();
});

it("does not start polling when the config flag is disabled", () => {
config.deploymentVersionCheckEnabled = false;
Version.buildNumber = "prod-build-123";
create();
expect(startPollingSpy).not.toHaveBeenCalled();
});

it("does not start polling when config failed to load", () => {
config.shouldThrow = true;
Version.buildNumber = "prod-build-123";
create();
expect(startPollingSpy).not.toHaveBeenCalled();
});

it("starts polling for a real build when the config flag is enabled", () => {
config.deploymentVersionCheckEnabled = true;
Version.buildNumber = "prod-build-123";
create();
expect(startPollingSpy).toHaveBeenCalledTimes(1);
});
});

describe("retry", () => {
it("reloads the page", () => {
const reload = vi.fn();
// location.reload is a non-writable, non-configurable own property (and is
// absent from Location.prototype) under this jsdom build, so it cannot be
// spied or reassigned directly. window.location itself is configurable,
// so swap the whole object for the test, then restore it.
const original = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: { ...original, reload },
});
try {
create().componentInstance.retry();
expect(reload).toHaveBeenCalledTimes(1);
} finally {
Object.defineProperty(window, "location", { configurable: true, value: original });
}
});
});
});
16 changes: 14 additions & 2 deletions frontend/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import { Component } from "@angular/core";
import { GuiConfigService } from "./common/service/gui-config.service";
import { DeploymentVersionService } from "./common/service/deployment-version/deployment-version.service";
import { Version } from "../environments/version";
import { UntilDestroy } from "@ngneat/until-destroy";

@UntilDestroy()
Expand All @@ -40,15 +42,25 @@ import { UntilDestroy } from "@ngneat/until-destroy";
export class AppComponent {
configLoaded = false;

constructor(private config: GuiConfigService) {
constructor(
private configService: GuiConfigService,
private deploymentVersionService: DeploymentVersionService
) {
// determine whether configuration was successfully loaded by APP_INITIALIZER
try {
// accessing env will throw if not loaded
void this.config.env;
void this.configService.env;
this.configLoaded = true;
} catch {
this.configLoaded = false;
}

// Poll for new deployments only when the config opts in (off by default),
// config actually loaded, and this isn't the "dev" placeholder build where
// no deployments occur.
if (this.configLoaded && this.configService.env.deploymentVersionCheckEnabled && Version.buildNumber !== "dev") {
this.deploymentVersionService.startPollingForUpdates();
}
}

retry(): void {
Expand Down
Loading
Loading