From b2b8da9c7ab4bb1140091229304f75066fbaf6cc Mon Sep 17 00:00:00 2001 From: bugkeep <1921817430@qq.com> Date: Sun, 26 Jul 2026 19:29:10 +0800 Subject: [PATCH] fix: stabilize app store install lifecycle --- store/helm.go | 131 ++++++++++----- web/src/HelmInstallModal.form.test.js | 227 ++++++++++++++++++++++++++ web/src/HelmInstallModal.js | 66 ++++++-- web/tests/ui/app-store-helpers.js | 25 ++- 4 files changed, 399 insertions(+), 50 deletions(-) create mode 100644 web/src/HelmInstallModal.form.test.js diff --git a/store/helm.go b/store/helm.go index 92274449..849a1b07 100644 --- a/store/helm.go +++ b/store/helm.go @@ -44,13 +44,15 @@ import ( ) const ( - helmOperationTimeout = 5 * time.Minute - helmInstallTimeout = 10 * time.Minute - helmCompatibilityTimeout = 2 * time.Minute - helmDiagnosticsTimeout = 15 * time.Second - helmDiagnosticsMaxEvents = 20 - helmDiagnosticsMessageLen = 240 - helmDiagnosticsEventLen = 360 + helmOperationTimeout = 5 * time.Minute + helmInstallTimeout = 10 * time.Minute + helmCompatibilityTimeout = 2 * time.Minute + helmChartLoadTimeout = 2 * time.Minute + helmDiagnosticsTimeout = 15 * time.Second + helmInstallOperationTimeout = helmChartLoadTimeout + helmCompatibilityTimeout + helmInstallTimeout + helmDiagnosticsTimeout + helmDiagnosticsMaxEvents = 20 + helmDiagnosticsMessageLen = 240 + helmDiagnosticsEventLen = 360 ) // ---------- Types ---------- @@ -150,8 +152,8 @@ func newHelmConfigWithLog(cfg *rest.Config, namespace string, logFn func(string, return actionConfig, nil } -func attachHelmCapabilities(actionConfig *action.Configuration, cfg *rest.Config, namespace string, logFn func(string, ...interface{})) { - capabilities, err := buildHelmCapabilities(cfg, namespace, logFn) +func attachHelmCapabilities(ctx context.Context, actionConfig *action.Configuration, cfg *rest.Config, logFn func(string, ...interface{})) { + capabilities, err := buildHelmCapabilities(ctx, cfg, logFn) if err != nil { logFn("WARNING: failed to build helm capabilities, using defaults: %v", err) capabilities = chartutil.DefaultCapabilities @@ -163,11 +165,19 @@ func helmWarningLog(format string, args ...interface{}) { logrus.Warnf(format, args...) } -func buildHelmCapabilities(cfg *rest.Config, namespace string, logFn func(string, ...interface{})) (*chartutil.Capabilities, error) { - dc, err := newRESTClientGetter(cfg, namespace).ToDiscoveryClient() +func buildHelmCapabilities(ctx context.Context, cfg *rest.Config, logFn func(string, ...interface{})) (*chartutil.Capabilities, error) { + if ctx == nil { + ctx = context.Background() + } + httpClient, err := rest.HTTPClientFor(cfg) + if err != nil { + return nil, fmt.Errorf("helm discovery HTTP client: %w", err) + } + discoveryClient, err := discovery.NewDiscoveryClientForConfigAndClient(cfg, httpClientWithContext(ctx, httpClient)) if err != nil { return nil, fmt.Errorf("helm discovery client: %w", err) } + dc := memory.NewMemCacheClient(discoveryClient) dc.Invalidate() kubeVersion, err := dc.ServerVersion() @@ -248,8 +258,33 @@ func shouldKeepHelmKubePrerelease(preRelease string) bool { // ---------- HTTP helper ---------- -func helmGet(url string) (*http.Response, error) { - req, err := http.NewRequest(http.MethodGet, url, nil) +type contextRoundTripper struct { + ctx context.Context + next http.RoundTripper +} + +func (t contextRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return t.next.RoundTrip(req.Clone(t.ctx)) +} + +func httpClientWithContext(ctx context.Context, base *http.Client) *http.Client { + if ctx == nil { + ctx = context.Background() + } + if base == nil { + base = http.DefaultClient + } + client := *base + transport := base.Transport + if transport == nil { + transport = http.DefaultTransport + } + client.Transport = contextRoundTripper{ctx: ctx, next: transport} + return &client +} + +func helmGet(ctx context.Context, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } @@ -259,9 +294,9 @@ func helmGet(url string) (*http.Response, error) { // ---------- Repo index ---------- -func fetchIndexFile(repoURL string) (*repo.IndexFile, error) { +func fetchIndexFile(ctx context.Context, repoURL string) (*repo.IndexFile, error) { indexURL := strings.TrimRight(repoURL, "/") + "/index.yaml" - resp, err := helmGet(indexURL) + resp, err := helmGet(ctx, indexURL) if err != nil { return nil, fmt.Errorf( "fetch index %q: %w", @@ -304,11 +339,13 @@ func fetchIndexFile(repoURL string) (*repo.IndexFile, error) { // FetchRepoIndex returns all charts listed in a Helm repo's index.yaml, or, for an // "oci://" repoURL, the single chart hosted at that OCI reference. func FetchRepoIndex(repoURL string) ([]HelmChartSummary, error) { + ctx, cancel := context.WithTimeout(context.Background(), helmChartLoadTimeout) + defer cancel() if isOCIRepo(repoURL) { - return fetchOCIChartSummary(repoURL) + return fetchOCIChartSummary(ctx, repoURL) } - idx, err := fetchIndexFile(repoURL) + idx, err := fetchIndexFile(ctx, repoURL) if err != nil { return nil, err } @@ -388,16 +425,16 @@ func isOCIChartTag(tag string) bool { return true } -func newOCIRegistryClient() (*registry.Client, error) { - return registry.NewClient(registry.ClientOptHTTPClient(proxypkg.ProxyHttpClient)) +func newOCIRegistryClient(ctx context.Context) (*registry.Client, error) { + return registry.NewClient(registry.ClientOptHTTPClient(httpClientWithContext(ctx, proxypkg.ProxyHttpClient))) } // pullOCIChart pulls the chart hosted at repoURL, resolving to the newest published // semver tag when version is empty. -func pullOCIChart(repoURL, version string) (*registry.PullResult, error) { +func pullOCIChart(ctx context.Context, repoURL, version string) (*registry.PullResult, error) { ref, resolvedVersion := resolveOCIChartRef(repoURL, version) - rc, err := newOCIRegistryClient() + rc, err := newOCIRegistryClient(ctx) if err != nil { return nil, fmt.Errorf("oci registry client: %w", err) } @@ -465,16 +502,16 @@ func latestOCISemverTag(tags []string) string { return versionedTags[0].tag } -func loadOCIChart(repoURL, version string) (*chart.Chart, error) { - pull, err := pullOCIChart(repoURL, version) +func loadOCIChart(ctx context.Context, repoURL, version string) (*chart.Chart, error) { + pull, err := pullOCIChart(ctx, repoURL, version) if err != nil { return nil, err } return loader.LoadArchive(bytes.NewReader(pull.Chart.Data)) } -func fetchOCIChartSummary(repoURL string) ([]HelmChartSummary, error) { - pull, err := pullOCIChart(repoURL, "") +func fetchOCIChartSummary(ctx context.Context, repoURL string) ([]HelmChartSummary, error) { + pull, err := pullOCIChart(ctx, repoURL, "") if err != nil { return nil, err } @@ -682,8 +719,18 @@ func redactionCandidates(raw string) []string { } func loadChart(chartName, repoURL, version string) (*chart.Chart, error) { + return loadChartWithContext(context.Background(), chartName, repoURL, version) +} + +func loadChartWithContext(parent context.Context, chartName, repoURL, version string) (*chart.Chart, error) { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, helmChartLoadTimeout) + defer cancel() + if isOCIRepo(repoURL) { - ch, err := loadOCIChart(repoURL, version) + ch, err := loadOCIChart(ctx, repoURL, version) if err != nil { return nil, fmt.Errorf( "load chart %q from OCI repo %q version %q: %w", @@ -696,7 +743,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) { return ch, nil } - idx, err := fetchIndexFile(repoURL) + idx, err := fetchIndexFile(ctx, repoURL) if err != nil { return nil, fmt.Errorf("load chart %q from repo %q version %q: fetch index.yaml failed: %w", chartName, redactURLForError(repoURL), version, err) } @@ -726,7 +773,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) { if ociVersion == "" { ociVersion = entry.Version } - ch, err := loadOCIChart(chartURL, ociVersion) + ch, err := loadOCIChart(ctx, chartURL, ociVersion) if err != nil { return nil, fmt.Errorf( "load chart %q from repo %q version %q: index.yaml resolved to OCI chart URL %q: %w", @@ -743,7 +790,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) { chartURL = strings.TrimRight(repoURL, "/") + "/" + strings.TrimLeft(chartURL, "/") } - resp, err := helmGet(chartURL) + resp, err := helmGet(ctx, chartURL) if err != nil { return nil, fmt.Errorf( "load chart %q from repo %q version %q: download chart archive %q failed: %w", @@ -1218,8 +1265,11 @@ func InstallHelmChart(cfg *rest.Config, releaseName, namespace, chartName, repoU return err } - attachHelmCapabilities(actionConfig, cfg, namespace, helmWarningLog) - if err := validateHelmChartCompatibility(context.Background(), cfg, actionConfig, releaseName, namespace, ch, vals); err != nil { + compatibilityCtx, cancelCompatibility := context.WithTimeout(context.Background(), helmCompatibilityTimeout) + attachHelmCapabilities(compatibilityCtx, actionConfig, cfg, helmWarningLog) + err = validateHelmChartCompatibility(compatibilityCtx, cfg, actionConfig, releaseName, namespace, ch, vals) + cancelCompatibility() + if err != nil { return err } install := action.NewInstall(actionConfig) @@ -1264,7 +1314,8 @@ func InstallHelmChartStream(ctx context.Context, lifecycle HelmInstallLifecycle, if streamCtx == nil { streamCtx = context.Background() } - installCtx := context.WithoutCancel(streamCtx) + installCtx, cancelInstall := context.WithTimeout(context.WithoutCancel(streamCtx), helmInstallOperationTimeout) + defer cancelInstall() send := func(line string) bool { if err := lifecycle.RecordLog(line); err != nil { logrus.Warnf("failed to persist Helm operation log: %v", err) @@ -1294,7 +1345,7 @@ func InstallHelmChartStream(ctx context.Context, lifecycle HelmInstallLifecycle, } return } - helmChart, err := loadChart(chartName, repoURL, version) + helmChart, err := loadChartWithContext(installCtx, chartName, repoURL, version) if err != nil { send("ERROR: " + err.Error()) if finishErr := lifecycle.Finish(err); finishErr != nil { @@ -1310,8 +1361,11 @@ func InstallHelmChartStream(ctx context.Context, lifecycle HelmInstallLifecycle, } return } - attachHelmCapabilities(actionConfig, cfg, namespace, logFn) - if err := validateHelmChartCompatibility(installCtx, cfg, actionConfig, releaseName, namespace, helmChart, vals); err != nil { + compatibilityCtx, cancelCompatibility := context.WithTimeout(installCtx, helmCompatibilityTimeout) + attachHelmCapabilities(compatibilityCtx, actionConfig, cfg, logFn) + err = validateHelmChartCompatibility(compatibilityCtx, cfg, actionConfig, releaseName, namespace, helmChart, vals) + cancelCompatibility() + if err != nil { send("ERROR: " + err.Error()) _ = lifecycle.Finish(err) return @@ -1374,8 +1428,11 @@ func UpgradeHelmRelease(cfg *rest.Config, releaseName, namespace, chartName, rep return err } - attachHelmCapabilities(actionConfig, cfg, namespace, helmWarningLog) - if err := validateHelmReleaseCompatibility(context.Background(), actionConfig, releaseName, namespace, ch, vals); err != nil { + compatibilityCtx, cancelCompatibility := context.WithTimeout(context.Background(), helmCompatibilityTimeout) + attachHelmCapabilities(compatibilityCtx, actionConfig, cfg, helmWarningLog) + err = validateHelmReleaseCompatibility(compatibilityCtx, actionConfig, releaseName, namespace, ch, vals) + cancelCompatibility() + if err != nil { return err } upgrade := action.NewUpgrade(actionConfig) diff --git a/web/src/HelmInstallModal.form.test.js b/web/src/HelmInstallModal.form.test.js new file mode 100644 index 00000000..785019f2 --- /dev/null +++ b/web/src/HelmInstallModal.form.test.js @@ -0,0 +1,227 @@ +/* eslint-env jest */ + +import React, {act} from "react"; +import {createRoot} from "react-dom/client"; +import HelmInstallModal from "./HelmInstallModal"; +import * as HelmBackend from "./backend/HelmBackend"; +import * as NamespaceBackend from "./backend/NamespaceBackend"; + +let mockForm; + +jest.mock("antd", () => { + const React = require("react"); + const component = tag => ({children}) => React.createElement(tag, null, children); + const Form = component("form"); + Form.useForm = () => [mockForm]; + Form.Item = component("div"); + const Typography = {Text: component("span")}; + return { + Alert: component("div"), + Button: ({children, onClick}) => React.createElement("button", {onClick}, children), + Form, + Input: component("input"), + Modal: ({children, footer}) => React.createElement("div", null, children, footer), + Select: component("select"), + Spin: component("span"), + Typography, + }; +}); + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({t: key => key}), +})); + +jest.mock("./backend/HelmBackend"); +jest.mock("./backend/NamespaceBackend"); + +describe("HelmInstallModal form initialization", () => { + let container; + let root; + let formValues; + let touchedFields; + let resolveNamespaces; + + beforeEach(() => { + global.IS_REACT_ACT_ENVIRONMENT = true; + Element.prototype.scrollIntoView = jest.fn(); + formValues = {}; + touchedFields = new Set(); + mockForm = { + getFieldValue: jest.fn(name => formValues[name]), + isFieldTouched: jest.fn(name => touchedFields.has(name)), + setFieldsValue: jest.fn(values => Object.assign(formValues, values)), + validateFields: jest.fn(), + }; + NamespaceBackend.getNamespaces.mockReturnValue(new Promise(resolve => { + resolveNamespaces = resolve; + })); + HelmBackend.getHelmChartValues.mockResolvedValue({status: "ok", data: ""}); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async() => { + await act(async() => root.unmount()); + container.remove(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + test("a late namespace response does not overwrite a release name entered by the user", async() => { + await act(async() => { + root.render( + + ); + }); + + formValues.releaseName = "e2e-casdoor-12345678"; + touchedFields.add("releaseName"); + + await act(async() => { + resolveNamespaces({status: "ok", data: [{name: "default"}]}); + await Promise.resolve(); + }); + + expect(formValues.releaseName).toBe("e2e-casdoor-12345678"); + expect(formValues.namespace).toBe("default"); + }); + + test("a response from the previous chart does not replace the current chart values", async() => { + let resolveOldValues; + let resolveNewValues; + NamespaceBackend.getNamespaces.mockResolvedValue({status: "ok", data: [{name: "default"}]}); + HelmBackend.getHelmChartValues + .mockReturnValueOnce(new Promise(resolve => { + resolveOldValues = resolve; + })) + .mockReturnValueOnce(new Promise(resolve => { + resolveNewValues = resolve; + })); + + await act(async() => { + root.render( + + ); + }); + await act(async() => { + root.render( + + ); + }); + + await act(async() => { + resolveNewValues({status: "ok", data: "current: true"}); + await Promise.resolve(); + }); + expect(container.querySelector("textarea").value).toBe("current: true"); + + await act(async() => { + resolveOldValues({status: "ok", data: "stale: true"}); + await Promise.resolve(); + }); + expect(container.querySelector("textarea").value).toBe("current: true"); + }); + + test("a stalled install stream falls back to the persisted task", async() => { + jest.useFakeTimers(); + NamespaceBackend.getNamespaces.mockResolvedValue({status: "ok", data: [{name: "default"}]}); + mockForm.validateFields.mockResolvedValue({ + releaseName: "demo-release", + namespace: "default", + version: "1.0.0", + }); + HelmBackend.installHelmChartStream.mockImplementation((_payload, onLine) => { + onLine("TASK_ID:42"); + return new Promise(() => {}); + }); + HelmBackend.getHelmOperationTask.mockReturnValue(new Promise(() => {})); + + await act(async() => { + root.render( + + ); + }); + const installButton = [...container.querySelectorAll("button")] + .find(button => button.textContent === "helm:Install"); + await act(async() => { + installButton.click(); + await Promise.resolve(); + }); + + await act(async() => { + jest.advanceTimersByTime(60_000); + await Promise.resolve(); + }); + + expect(HelmBackend.getHelmOperationTask).toHaveBeenCalledWith("42"); + }); + + test("completion from a previous chart stream does not finish the current modal", async() => { + let resolveOldStream; + NamespaceBackend.getNamespaces.mockResolvedValue({status: "ok", data: [{name: "default"}]}); + mockForm.validateFields.mockResolvedValue({ + releaseName: "old-release", + namespace: "default", + version: "1.0.0", + }); + HelmBackend.installHelmChartStream.mockReturnValue(new Promise(resolve => { + resolveOldStream = resolve; + })); + + await act(async() => { + root.render( + + ); + }); + const installButton = [...container.querySelectorAll("button")] + .find(button => button.textContent === "helm:Install"); + await act(async() => { + installButton.click(); + await Promise.resolve(); + }); + await act(async() => { + root.render( + + ); + }); + + await act(async() => { + resolveOldStream("DONE"); + await Promise.resolve(); + }); + + expect([...container.querySelectorAll("button")] + .some(button => button.textContent === "general:Done")).toBe(false); + }); +}); diff --git a/web/src/HelmInstallModal.js b/web/src/HelmInstallModal.js index 02390f3a..3352e0f3 100644 --- a/web/src/HelmInstallModal.js +++ b/web/src/HelmInstallModal.js @@ -14,6 +14,7 @@ import { const {Text} = Typography; const helmOperationTaskNotFoundCode = "helm_task_not_found"; +const helmInstallStreamIdleTimeout = 30 * 1000; export default function HelmInstallModal({open, chart, onClose, onInstalled}) { const {t} = useTranslation(); @@ -34,7 +35,10 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { const taskIdentityRef = useRef(null); const pollTimerRef = useRef(null); const pollGenerationRef = useRef(0); + const initializationGenerationRef = useRef(0); const streamAbortRef = useRef(null); + const streamIdleTimerRef = useRef(null); + const streamIdleControllerRef = useRef(null); const mountedRef = useRef(true); const submittingRef = useRef(false); @@ -46,6 +50,15 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { } }; + const stopStreamIdleTimer = (controller = null) => { + if (controller && streamIdleControllerRef.current !== controller) {return;} + if (streamIdleTimerRef.current) { + clearTimeout(streamIdleTimerRef.current); + streamIdleTimerRef.current = null; + } + streamIdleControllerRef.current = null; + }; + const forgetTask = (storageKey = taskStorageKeyRef.current) => { removeStoredHelmTask(storageKey); if (!storageKey || taskStorageKeyRef.current === storageKey) { @@ -148,7 +161,12 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { }; useEffect(() => { + const generation = initializationGenerationRef.current + 1; + initializationGenerationRef.current = generation; if (!open || !chart) {return;} + const isCurrentInitialization = () => ( + mountedRef.current && generation === initializationGenerationRef.current + ); setError(null); setStorageWarning(null); setLogs([]); @@ -161,10 +179,19 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { taskIdentityRef.current = null; submittingRef.current = false; stopTaskPolling(); + stopStreamIdleTimer(); streamAbortRef.current?.abort(); streamAbortRef.current = null; const savedTask = findStoredHelmTask(chart.chartName); + const initialFields = { + releaseName: savedTask?.releaseName || chart.chartName, + version: chart.version ?? "", + }; + if (savedTask?.namespace) { + initialFields.namespace = savedTask.namespace; + } + form.setFieldsValue(initialFields); if (savedTask) { taskIdRef.current = savedTask.taskId; setActiveTaskId(savedTask.taskId); @@ -176,16 +203,14 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { } NamespaceBackend.getNamespaces().then(res => { - if (!mountedRef.current) {return;} + if (!isCurrentInitialization()) {return;} if (res.status === "ok") { const ns = res.data ?? []; setNamespaces(ns); const def = ns.find(n => n.name === "default") ? "default" : (ns[0]?.name ?? "default"); - form.setFieldsValue({ - releaseName: savedTask?.releaseName || chart.chartName, - namespace: savedTask?.namespace || def, - version: chart.version ?? "", - }); + if (!form.isFieldTouched("namespace") && !form.getFieldValue("namespace")) { + form.setFieldsValue({namespace: def}); + } } }); @@ -194,7 +219,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { setValuesYAML(""); HelmBackend.getHelmChartValues(chart.chartName, chart.repoURL, chart.version ?? "") .then(res => { - if (!mountedRef.current) {return;} + if (!isCurrentInitialization()) {return;} if (res.status === "ok") { setValuesYAML(res.data ?? ""); } else { @@ -202,7 +227,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { } }) .finally(() => { - if (mountedRef.current) {setValuesLoading(false);} + if (isCurrentInitialization()) {setValuesLoading(false);} }); } }, [open, chart, form]); @@ -212,6 +237,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { return () => { mountedRef.current = false; stopTaskPolling(); + stopStreamIdleTimer(); streamAbortRef.current?.abort(); streamAbortRef.current = null; }; @@ -225,6 +251,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { const handleClose = () => { stopTaskPolling(); + stopStreamIdleTimer(); streamAbortRef.current?.abort(); streamAbortRef.current = null; taskIdRef.current = null; @@ -258,8 +285,21 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { setError(null); setLogs([]); const streamController = new AbortController(); + stopStreamIdleTimer(); streamAbortRef.current?.abort(); streamAbortRef.current = streamController; + const fallBackToTaskPolling = () => { + stopStreamIdleTimer(streamController); + if (!mountedRef.current || streamAbortRef.current !== streamController || !taskIdRef.current) {return;} + streamController.abort(); + monitorTask(taskIdRef.current, taskStorageKeyRef.current, taskIdentityRef.current); + }; + const resetStreamIdleTimer = () => { + if (!taskIdRef.current) {return;} + stopStreamIdleTimer(); + streamIdleControllerRef.current = streamController; + streamIdleTimerRef.current = setTimeout(fallBackToTaskPolling, helmInstallStreamIdleTimeout); + }; HelmBackend.installHelmChartStream( { @@ -271,7 +311,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { valuesYAML, }, line => { - if (!mountedRef.current) {return;} + if (!mountedRef.current || streamAbortRef.current !== streamController) {return;} if (line.startsWith("TASK_ID:")) { const taskId = line.slice("TASK_ID:".length).trim(); const storageKey = helmTaskStorageKey(chart.chartName, values.namespace, values.releaseName); @@ -298,12 +338,14 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { } else { setLogs(prev => [...prev, line]); } + resetStreamIdleTimer(); }, streamController.signal ) .then(status => { - if (!mountedRef.current) {return;} + if (!mountedRef.current || streamAbortRef.current !== streamController) {return;} if (status === "DONE") { + stopStreamIdleTimer(streamController); setDone(true); setInstalling(false); setPollingPaused(false); @@ -313,7 +355,8 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { } }) .catch(e => { - if (!mountedRef.current) {return;} + if (!mountedRef.current || streamAbortRef.current !== streamController) {return;} + stopStreamIdleTimer(streamController); if (streamController.signal.aborted) {return;} if (taskIdRef.current) { monitorTask(taskIdRef.current, taskStorageKeyRef.current, taskIdentityRef.current); @@ -326,6 +369,7 @@ export default function HelmInstallModal({open, chart, onClose, onInstalled}) { }) .finally(() => { if (streamAbortRef.current === streamController) { + stopStreamIdleTimer(streamController); streamAbortRef.current = null; } }); diff --git a/web/tests/ui/app-store-helpers.js b/web/tests/ui/app-store-helpers.js index 16e8d973..e87bc3f1 100644 --- a/web/tests/ui/app-store-helpers.js +++ b/web/tests/ui/app-store-helpers.js @@ -3,6 +3,7 @@ const {expect} = require("@playwright/test"); const {expectOkJson} = require("./e2e-helpers"); const API_UNINSTALL_HELM_RELEASE = "/api/uninstall-helm-release"; +const API_INSTALL_HELM_CHART_STREAM = "/api/install-helm-chart-stream"; const API_ADD_HELM_REPO = "/api/add-helm-repo"; const API_GET_HELM_REPOS = "/api/get-helm-repos"; const API_DELETE_HELM_REPO = "/api/delete-helm-repo"; @@ -10,6 +11,19 @@ const INSTALL_DONE_TIMEOUT_MS = Number(process.env.E2E_APP_INSTALL_DONE_TIMEOUT_ const HTTP_CHECK_TIMEOUT_MS = Number(process.env.E2E_APP_HTTP_TIMEOUT_MS) || 180_000; const HTTP_CHECK_INTERVAL_MS = 3_000; +async function expectHelmCleanup(response) { + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + const releaseMissing = body.status === "error" && + typeof body.msg === "string" && + body.msg.includes("Release not loaded:") && + body.msg.includes("release: not found"); + if (!releaseMissing) { + expect(body.status).toBe("ok"); + } + return body; +} + const installedReleasesFixture = async({page}, use) => { const installedReleases = []; await use(installedReleases); @@ -20,7 +34,7 @@ const installedReleasesFixture = async({page}, use) => { const uninstall = await page.context().request.post(API_UNINSTALL_HELM_RELEASE, { data: {releaseName: release.name, namespace: release.namespace}, }); - await expectOkJson(uninstall); + await expectHelmCleanup(uninstall); } catch (error) { cleanupErrors.push(`${release.namespace}/${release.name}: ${error.message}`); } @@ -151,8 +165,15 @@ async function installAppFromAppStore(page, {repoName, chartName, releaseName, n await dialog.getByLabel("Release name").fill(releaseName); await textarea.fill(valuesYAML); + const installRequestPromise = page.waitForRequest(request => + request.url().includes(API_INSTALL_HELM_CHART_STREAM) && request.method() === "POST" + ); await dialog.getByRole("button", {name: "Install"}).click(); - installedReleases.push({name: releaseName, namespace}); + const submittedInstall = (await installRequestPromise).postDataJSON(); + if (submittedInstall?.releaseName && submittedInstall?.namespace) { + installedReleases.push({name: submittedInstall.releaseName, namespace: submittedInstall.namespace}); + } + expect(submittedInstall).toMatchObject({releaseName, namespace}); await waitForInstallDone(dialog); await dialog.getByRole("button", {name: "Done"}).click();