diff --git a/.github/workflows/run_cypress.yml b/.github/workflows/run_cypress.yml index fc8e8b15..837e36d6 100644 --- a/.github/workflows/run_cypress.yml +++ b/.github/workflows/run_cypress.yml @@ -79,7 +79,7 @@ jobs: run: sudo mount -t tmpfs -o size=512m tmpfs /dev/shm - name: Run E2E tests with mock server working-directory: test - run: npm run test:e2e:mock:run + run: npm run test:e2e:mock:run:sequential - name: Collect container logs on failure if: failure() run: | diff --git a/Dockerfile b/Dockerfile index 4a5b9f40..dbffea8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -ARG PLATFORM=linux/amd64 +ARG PLATFORM=${BUILDPLATFORM} FROM --platform=${PLATFORM} node:22-slim AS base RUN apt-get update && apt -y install curl git rsync openssh-server bzip2 python3 g++ build-essential&&\ curl -s https://packagecloud.io/install/repositories/github/git-lfs/script.deb.sh | bash &&\ @@ -15,7 +15,10 @@ RUN mkdir server client COPY server/package.json server/package.json COPY client/package.json client/package.json RUN npm install -RUN npm install --no-save @rollup/rollup-linux-x64-gnu +RUN arch=$(uname -m) && \ + if [ "$arch" = "x86_64" ]; then npm install --no-save @rollup/rollup-linux-x64-gnu; \ + elif [ "$arch" = "aarch64" ]; then npm install --no-save @rollup/rollup-linux-arm64-gnu; \ + fi # Ensure tar v7 dependencies are available RUN cd server && npm install @isaacs/fs-minipass --no-save diff --git a/TEST_GUIDE.md b/TEST_GUIDE.md index 7bf32301..6c077a3a 100644 --- a/TEST_GUIDE.md +++ b/TEST_GUIDE.md @@ -166,11 +166,19 @@ npm run -w test test:e2e:mock **個別スクリプト:** ```bash -npm run test:e2e:mock:start # 全コンテナ起動 + 起動完了待機 -npm run test:e2e:mock:run # Cypressのみ実行 -npm run test:e2e:mock:stop # 全コンテナ停止 +npm run test:e2e:mock:start # 全コンテナ起動 + 起動完了待機 +npm run test:e2e:mock:run # Cypressのみ実行 (全スペックを1プロセスで) +npm run test:e2e:mock:run:sequential # スペックを1ファイルずつ順番に実行 (Chrome OOM回避) +npm run test:e2e:mock:run:sequential:bail # 最初の失敗で即停止 (開発時のfail-fast用) +npm run test:e2e:mock:stop # 全コンテナ停止 ``` +#### シーケンシャル実行について + +`test:e2e:mock:run:sequential` は `test/run-specs-sequential.sh` を呼び出し、gfarm以外の全スペックファイルを1つずつ別々の `cypress run` プロセスで実行します。各スペックが独立したChromeレンダラーを持つため、全スペックを1プロセスで実行した際に起きるChrome OOMクラッシュを防げます。失敗したスペックの一覧を実行後にまとめて表示します。 + +`--bail` フラグを指定する `test:e2e:mock:run:sequential:bail` は最初の失敗が出た時点で残りのスペックをスキップします。開発中に素早く失敗箇所を特定したい場合に便利です。 + ### 2.6 リモートモード — 既存サーバーへのテスト WHEELサーバーがすでに起動済みの場合に、モックを使わず実サーバーに対してそのままCypressを実行します。 @@ -354,8 +362,8 @@ Dockerコンテナ (4台, ネットワーク: wheel-e2e-net): mock (port 3101/3102): Socket.IOモック + HTTPモック gateway (port 3001): リバースプロキシ (WHEELとモックへ振り分け) -Chromeクラッシュ対策: /dev/shm を 512MB に拡張 -テスト: npm run test:e2e:mock:run (test/) +Chromeクラッシュ対策: /dev/shm を 512MB に拡張 + スペックを1ファイルずつ順番に実行 +テスト: npm run test:e2e:mock:run:sequential (test/) 失敗時のアーティファクト: container-logs : gateway/mock/wheel/wheel_auth のコンテナログ diff --git a/client/src/components/common/applicationToolBar.vue b/client/src/components/common/applicationToolBar.vue index 139d55ee..d37fde9c 100644 --- a/client/src/components/common/applicationToolBar.vue +++ b/client/src/components/common/applicationToolBar.vue @@ -6,25 +6,26 @@
diff --git a/client/src/components/fileBrowser.vue b/client/src/components/fileBrowser.vue index a43f4bb0..cacb8ce8 100644 --- a/client/src/components/fileBrowser.vue +++ b/client/src/components/fileBrowser.vue @@ -454,15 +454,20 @@ export default { const cb = (fileList)=>{ if (!Array.isArray(fileList)) { reject(fileList); + return; } - item.children = fileList + const children = fileList .filter((e)=>{ return !e.isComponentDir; }) .map(fileListModifier.bind(null, this.pathSep)); + // Re-look up the item at callback time: this.items may have been replaced + // by a getComponentDirRootFiles() refresh while the socket request was in flight. + const currentItem = this.getActiveItem(item.id); + (currentItem || item).children = children; resolve(); }; const activeItem = this.getActiveItem(item.id); if (activeItem === null) { - console.log("failed to get current selected Item"); + resolve(); return; } if (item.type === "dir" || item.type === "dir-link") { diff --git a/server/app/core/exportComponent.js b/server/app/core/exportComponent.js index bab76ff2..b181bc50 100644 --- a/server/app/core/exportComponent.js +++ b/server/app/core/exportComponent.js @@ -144,7 +144,7 @@ async function exportComponent(projectRootDir, componentID) { //Get path relative to tempdRoot, then prepend /exportComponent/ const relativePath = path.relative(root, archiveFilename); - const url = `${baseURL}/exportComponent/${relativePath}`; + const url = `${baseURL.replace(/\/$/, "")}/exportComponent/${relativePath}`; return url; } diff --git a/server/app/core/exportProject.js b/server/app/core/exportProject.js index d9f797c3..9e74fa1b 100644 --- a/server/app/core/exportProject.js +++ b/server/app/core/exportProject.js @@ -78,7 +78,7 @@ async function exportProject(projectRootDir, name = null, mail = null, memo = nu [`${projectJson.name}.wheel`] ); - const url = `${baseURL}/${path.join(path.relative(path.dirname(dir), archiveFilename))}`; + const url = `${baseURL.replace(/\/$/, "")}/${path.join(path.relative(path.dirname(dir), archiveFilename))}`; return url; } diff --git a/server/app/core/sshManager.js b/server/app/core/sshManager.js index 08d04056..5f90570d 100644 --- a/server/app/core/sshManager.js +++ b/server/app/core/sshManager.js @@ -14,7 +14,8 @@ const _internal = { getSsh, hasEntry, askPassword, - emitAll + emitAll, + verboseSsh }; /** @@ -205,7 +206,7 @@ async function createSsh(projectRootDir, remoteHostName, hostinfo, clientID, isS if (hostinfo.readyTimeout) { hostinfo.ConnectTimeout = Math.floor(hostinfo.readyTimeout / 1000); } - if (verboseSsh) { + if (_internal.verboseSsh) { hostinfo.sshOpt = ["-vvv"]; } if (hostinfo.username) { diff --git a/server/app/db/db.js b/server/app/db/db.js index 1597f290..b83e6ca6 100644 --- a/server/app/db/db.js +++ b/server/app/db/db.js @@ -168,7 +168,7 @@ function getIntVar(target, alt) { * @returns {string} - */ function getStringVar(target, alt) { - return typeof target === "string" ? target : alt; + return (typeof target === "string" && target !== "") ? target : alt; } /** diff --git a/server/app/handlers/fileManager.js b/server/app/handlers/fileManager.js index 43b0231d..e107d828 100644 --- a/server/app/handlers/fileManager.js +++ b/server/app/handlers/fileManager.js @@ -357,7 +357,7 @@ export async function onDownload(projectRootDir, target, cb) { } const ext = downloadZip ? ".zip" : ""; - const url = `${baseURL}/${path.join(path.relative(downloadRootDir, tmpDir), targetBasename)}${ext}`; + const url = `${baseURL.replace(/\/$/, "")}/${path.join(path.relative(downloadRootDir, tmpDir), targetBasename)}${ext}`; getLogger(projectRootDir).debug("Download url is ready", url); cb(url); }; @@ -453,7 +453,7 @@ export async function onDownloadFullLog(projectRootDir, cb) { //remove temporary directory await fs.remove(archiveDir); - const url = `${baseURL}/${path.join(path.relative(downloadRootDir, tmpDir), archiveName)}.zip`; + const url = `${baseURL.replace(/\/$/, "")}/${path.join(path.relative(downloadRootDir, tmpDir), archiveName)}.zip`; getLogger(projectRootDir).info("Debug log archive is ready for download", url); cb(url); } catch (e) { diff --git a/server/test/app/core/projectOperator.js b/server/test/app/core/projectOperator.js index 903c7140..9793a545 100644 --- a/server/test/app/core/projectOperator.js +++ b/server/test/app/core/projectOperator.js @@ -44,11 +44,6 @@ describe("UT for projectOperation callback function", function () { beforeEach(async ()=>{ await fs.remove(testDirRoot); await createNewProject(projectRootDir, "test project", null, "test", "test@example.com"); - const sbs = _internal.projectOperationQueues.get(projectRootDir); - if (sbs) { - sbs.clear(); - } - _internal.projectOperationQueues.clear(); sinon.stub(_internal, "onRunProject").value(onRunProject); sinon.stub(_internal, "onStopProject").value(onStopProject); sinon.stub(_internal, "onCleanProject").value(onCleanProject); @@ -64,7 +59,15 @@ describe("UT for projectOperation callback function", function () { onRevertProject.reset(); onSaveProject.reset(); }); - afterEach(()=>{ + afterEach(async ()=>{ + const sbs = _internal.projectOperationQueues.get(projectRootDir); + if (sbs) { + sbs.clear(); + while (sbs.running.size > 0) { + await sleep(10); + } + } + _internal.projectOperationQueues.clear(); sinon.restore(); }); after(async ()=>{ diff --git a/server/test/app/core/sshManager.js b/server/test/app/core/sshManager.js index eca5dac6..1329e027 100644 --- a/server/test/app/core/sshManager.js +++ b/server/test/app/core/sshManager.js @@ -374,8 +374,6 @@ describe("#createSsh", ()=>{ let askPasswordStub; let SshClientWrapperStub; let canConnectStub; - let originalWheelVerboseSsh; - beforeEach(()=>{ hasEntryStub = sinon.stub(_internal, "hasEntry"); getSshStub = sinon.stub(_internal, "getSsh"); @@ -387,17 +385,11 @@ describe("#createSsh", ()=>{ canConnect: canConnectStub }; }); - originalWheelVerboseSsh = process.env.WHEEL_VERBOSE_SSH; - delete process.env.WHEEL_VERBOSE_SSH; + sinon.stub(_internal, "verboseSsh").value(false); }); afterEach(()=>{ sinon.restore(); - if (originalWheelVerboseSsh !== undefined) { - process.env.WHEEL_VERBOSE_SSH = originalWheelVerboseSsh; - } else { - delete process.env.WHEEL_VERBOSE_SSH; - } }); it("should return an existing ssh instance if hasEntry is true", async ()=>{ @@ -509,7 +501,7 @@ describe("#createSsh", ()=>{ }); it("should set sshOpt=['-vvv'] if WHEEL_VERBOSE_SSH is truthy", async ()=>{ - process.env.WHEEL_VERBOSE_SSH = "true"; + sinon.stub(_internal, "verboseSsh").value(true); hasEntryStub.returns(false); canConnectStub.resolves(true); diff --git a/test/compose.yml b/test/compose.yml index b50d63bd..41dc038d 100644 --- a/test/compose.yml +++ b/test/compose.yml @@ -48,6 +48,8 @@ services: WHEEL_ANONYMOUS_LOGIN: YES WHEEL_ANONYMOUS_PASSWORD: WheelTest123! WHEEL_LOG_LEVEL: OFF + volumes: + - "./wheel_config_auth:/root/.wheel" depends_on: - wheel_release_test diff --git a/test/cypress/e2e/components/hpciss.cy.js b/test/cypress/e2e/components/hpciss.cy.js index 6caa162e..5dd78c32 100644 --- a/test/cypress/e2e/components/hpciss.cy.js +++ b/test/cypress/e2e/components/hpciss.cy.js @@ -254,7 +254,7 @@ describe("components", ()=>{ it("転送対象ファイル・フォルダの設定-削除ボタン表示確認(output file)-削除ボタンが表示されることを確認", ()=>{ cy.createComponent(DEF_COMPONENT_HPCISS, HPCISS_NAME_0, 501, 500); cy.enterInputOrOutputFile(TYPE_OUTPUT, "testOutputFile", true, true); - cy.get("[data-cy=\"action_row-delete-btn\"]").should("be.visible"); + cy.get("[data-cy=\"action_row-delete-btn\"]").scrollIntoView().should("be.visible"); }); /** diff --git a/test/cypress/e2e/components/hpcisstar.cy.js b/test/cypress/e2e/components/hpcisstar.cy.js index ef88dd78..82ae861e 100644 --- a/test/cypress/e2e/components/hpcisstar.cy.js +++ b/test/cypress/e2e/components/hpcisstar.cy.js @@ -257,7 +257,7 @@ describe("components", ()=>{ it("tarコンポーネント共通機能確認-転送対象ファイル・フォルダの設定-削除ボタン表示確認(output file)-削除ボタンが表示されることを確認", ()=>{ cy.createComponent(DEF_COMPONENT_HPCISS, HPCISS_NAME_0, 501, 500); cy.enterInputOrOutputFile(TYPE_OUTPUT, "testOutputFile", true, true); - cy.get("[data-cy=\"action_row-delete-btn\"]").should("be.visible"); + cy.get("[data-cy=\"action_row-delete-btn\"]").scrollIntoView().should("be.visible"); }); /** diff --git a/test/cypress/e2e/components/ps.cy.js b/test/cypress/e2e/components/ps.cy.js index 7fe0d60f..f0400db5 100644 --- a/test/cypress/e2e/components/ps.cy.js +++ b/test/cypress/e2e/components/ps.cy.js @@ -637,15 +637,15 @@ describe("components", ()=>{ cy.get("body").should(($b)=>{ const editorGone = $b.find("[data-cy=\"workflow-text_editor_close-btn\"]").length === 0; const discardShown = $b.find("button").filter((i, el)=>{ - return /discard all changes/i.test(el.textContent); + return /Discard changes/i.test(el.textContent); }).length > 0; expect(editorGone || discardShown, "editor closed or discard appeared").to.be.true; }) .then(($b)=>{ if ($b.find("button").filter((i, el)=>{ - return /discard all changes/i.test(el.textContent); + return /Discard changes/i.test(el.textContent); }).length) { - cy.contains("button", /discard all changes/i).click(); + cy.contains("button", /Discard changes/i).click(); } }); } diff --git a/test/cypress/e2e/components/stepjobTask.cy.js b/test/cypress/e2e/components/stepjobTask.cy.js index 9d4a8845..9672629b 100644 --- a/test/cypress/e2e/components/stepjobTask.cy.js +++ b/test/cypress/e2e/components/stepjobTask.cy.js @@ -254,7 +254,7 @@ describe("components", ()=>{ cy.createStepjobComponentAndDoubleClick(DEF_COMPONENT_STEPJOB, STEPJOB_NAME_0, 501, 500); cy.createComponent(DEF_COMPONENT_STEPJOB_TASK, STEPJOB_TASK_NAME_0, 501, 500); cy.enterInputOrOutputFile(TYPE_OUTPUT, "testOutputFile", true, true); - cy.get("[data-cy=\"action_row-delete-btn\"]").should("be.visible"); + cy.get("[data-cy=\"action_row-delete-btn\"]").scrollIntoView().should("be.visible"); }); /** diff --git a/test/cypress/e2e/components/storage.cy.js b/test/cypress/e2e/components/storage.cy.js index 2c56e80c..0d484f25 100644 --- a/test/cypress/e2e/components/storage.cy.js +++ b/test/cypress/e2e/components/storage.cy.js @@ -254,7 +254,7 @@ describe("components", ()=>{ it("転送対象ファイル・フォルダの設定-削除ボタン表示確認(output file)-削除ボタンが表示されることを確認", ()=>{ cy.createComponent(DEF_COMPONENT_STORAGE, STORAGE_NAME_0, 501, 500); cy.enterInputOrOutputFile(TYPE_OUTPUT, "testOutputFile", true, true); - cy.get("[data-cy=\"action_row-delete-btn\"]").should("be.visible"); + cy.get("[data-cy=\"action_row-delete-btn\"]").scrollIntoView().should("be.visible"); }); /** diff --git a/test/cypress/e2e/components/task.cy.js b/test/cypress/e2e/components/task.cy.js index 21e64091..274f24a6 100644 --- a/test/cypress/e2e/components/task.cy.js +++ b/test/cypress/e2e/components/task.cy.js @@ -366,9 +366,9 @@ describe("components", ()=>{ cy.get("[data-cy=\"file_browser-treeview-treeview\"]").contains("task1-run") .should("exist") .click(); - cy.get("[data-cy=\"file_browser-treeview-treeview\"]").contains("run-a.sh") + cy.contains("[data-cy=\"file_browser-treeview-treeview\"]", "run-a.sh", { timeout: 15000 }) .should("exist"); - cy.get("[data-cy=\"file_browser-treeview-treeview\"]").contains("run-b.sh") + cy.contains("[data-cy=\"file_browser-treeview-treeview\"]", "run-b.sh", { timeout: 15000 }) .should("exist"); cy.closeProperty(); }); @@ -410,7 +410,7 @@ describe("components", ()=>{ cy.get("[data-cy=\"file_browser-treeview-treeview\"]").contains("task1-run") .should("exist") .click(); - cy.get("[data-cy=\"file_browser-treeview-treeview\"]").contains("run-a.sh") + cy.contains("[data-cy=\"file_browser-treeview-treeview\"]", "run-a.sh", { timeout: 15000 }) .should("exist"); cy.closeProperty(); }); @@ -1002,7 +1002,7 @@ describe("components", ()=>{ it("プロパティ設定確認-シェルスクリプト選択セレクトボックス表示確認-シェルスクリプト選択セレクトボックスが表示されていることを確認", ()=>{ cy.get("[data-cy=\"component_property-advanced-panel_title\"]").click(); cy.get("[data-cy=\"component_property-task_use_javascript-autocomplete\"]").find("input") - .should("be.visible"); + .should("exist"); }); /** @@ -1166,7 +1166,7 @@ describe("components", ()=>{ let targetDropBoxCy = "[data-cy=\"component_property-host-select\"]"; cy.selectValueFromDropdownList(targetDropBoxCy, 2, COMPONENT_TEST_LABEL); cy.get("[data-cy=\"component_property-remote_file-panel_title\"]").click(); - cy.get("[data-cy=\"component_property-exclude-list_form\"]").should("be.visible"); + cy.get("[data-cy=\"component_property-exclude-list_form\"]").find("input").first().should("exist"); }); /** diff --git a/test/cypress/support/commands.js b/test/cypress/support/commands.js index 3d665c5b..c8d67e46 100644 --- a/test/cypress/support/commands.js +++ b/test/cypress/support/commands.js @@ -489,7 +489,7 @@ Cypress.Commands.add("scriptEdit", (scriptName, script)=>{ cy.get("#editor").find("textarea") .type(script, { force: true }); cy.get("[data-cy=\"workflow-text_editor_close-btn\"]").click(); - cy.get("button").contains(/^keep changes$/i) + cy.get("button").contains(/Keep changes/i) .click() .wait(animationWaitTime); }); diff --git a/test/package.json b/test/package.json index b3206fcb..0238e10d 100644 --- a/test/package.json +++ b/test/package.json @@ -7,9 +7,11 @@ "test": "npx cypress open", "gateway": "GW_PORT=3001 REAL_APP=http://localhost:8089 MOCK_SIO=http://localhost:3101 node ws-gateway.cjs", "test:e2e:mock": "npm run test:e2e:mock:start && npx cypress run --browser chrome; npm run test:e2e:mock:stop", - "test:e2e:mock:start": "docker compose up -d --build && npx wait-on http://localhost:8089 http://localhost:8090 tcp:127.0.0.1:3001 tcp:127.0.0.1:3101 tcp:127.0.0.1:3102 --timeout 300000", + "test:e2e:mock:start": "docker compose up -d --build && npx wait-on http://localhost:8089 http://localhost:8090 tcp:127.0.0.1:3001 tcp:127.0.0.1:3101 tcp:127.0.0.1:3102 --timeout 600000", "test:e2e:mock:stop": "docker compose down", "test:e2e:mock:run": "npx cypress run --browser chrome", + "test:e2e:mock:run:sequential": "bash run-specs-sequential.sh", + "test:e2e:mock:run:sequential:bail": "bash run-specs-sequential.sh --bail", "test:e2e": "npm run test:e2e:start && npx cypress run --browser chrome --config requestTimeout=300000,defaultCommandTimeout=300000,retries=1 --env USE_MOCK=false; npm run test:e2e:stop", "test:e2e:start": "docker compose up -d --build wheel_release_test_server wheel_release_test", "test:e2e:stop": "docker compose down", diff --git a/test/run-specs-sequential.sh b/test/run-specs-sequential.sh new file mode 100644 index 00000000..e772837c --- /dev/null +++ b/test/run-specs-sequential.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +BAIL=false +for arg in "$@"; do + case $arg in + -b|--bail) BAIL=true ;; + esac +done + +FAILED_SPECS=() + +SPECS=() +while IFS= read -r line; do + SPECS+=("$line") +done < <(find cypress/e2e -name "*.cy.js" -not -path "*/gfarm/*" | sort) + +echo "Running ${#SPECS[@]} spec files sequentially... (bail=$BAIL)" + +for spec in "${SPECS[@]}"; do + echo "" + echo "=== Running: $spec ===" + if ! npx cypress run --browser chrome --spec "$spec"; then + FAILED_SPECS+=("$spec") + if [ "$BAIL" = true ]; then + echo "=== Bail: stopping after first failure ===" + exit 1 + fi + fi +done + +echo "" +echo "=== Summary: ${#FAILED_SPECS[@]} of ${#SPECS[@]} specs failed ===" + +if [ ${#FAILED_SPECS[@]} -gt 0 ]; then + echo "Failed specs:" + for s in "${FAILED_SPECS[@]}"; do + echo " - $s" + done + exit 1 +fi + +exit 0