From ee6d0e68b5557867389eb7ffc959cfd88a78cd29 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 09:48:40 +0000 Subject: [PATCH 1/4] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/278 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..f8bf83ed --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-12T09:48:40.302Z for PR creation at branch issue-278-d92a50df7e27 for issue https://github.com/ProverCoderAI/docker-git/issues/278 \ No newline at end of file From 87ede21d99ec83b04e758ac1791ad272f74d20e1 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 10:17:28 +0000 Subject: [PATCH 2/4] feat(ci): add cross-platform final build checks --- .changeset/cross-platform-final-build.md | 6 +++ .github/actions/setup/action.yml | 1 + .github/workflows/final-build.yml | 46 +++++++++++++++++++ .gitkeep | 1 - package.json | 10 ++-- packages/app/package.json | 10 ++-- .../package-scripts-cross-platform.test.ts | 34 ++++++++++++++ packages/docker-git-session-sync/package.json | 2 +- scripts/mark-executable.mjs | 23 ++++++++++ 9 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 .changeset/cross-platform-final-build.md create mode 100644 .github/workflows/final-build.yml delete mode 100644 .gitkeep create mode 100644 packages/app/tests/docker-git/package-scripts-cross-platform.test.ts create mode 100644 scripts/mark-executable.mjs diff --git a/.changeset/cross-platform-final-build.md b/.changeset/cross-platform-final-build.md new file mode 100644 index 00000000..260c76bb --- /dev/null +++ b/.changeset/cross-platform-final-build.md @@ -0,0 +1,6 @@ +--- +"@prover-coder-ai/docker-git": patch +"@prover-coder-ai/docker-git-session-sync": patch +--- + +Add portable launch/build scripts and CI final-build verification across Linux, macOS, and Windows. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index cb33036e..0b19744d 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -22,6 +22,7 @@ runs: with: node-version: ${{ inputs.node-version }} - name: Install OpenSSH client + if: runner.os == 'Linux' shell: bash run: | if command -v ssh >/dev/null 2>&1 && command -v ssh-keygen >/dev/null 2>&1; then diff --git a/.github/workflows/final-build.yml b/.github/workflows/final-build.yml new file mode 100644 index 00000000..70e931f7 --- /dev/null +++ b/.github/workflows/final-build.yml @@ -0,0 +1,46 @@ +name: Final Build + +on: + workflow_dispatch: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + final-build: + name: Final build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + uses: ./.github/actions/setup + with: + bun-version: 1.3.11 + node-version: 24.14.0 + - name: Build final workspace packages + run: bun run build + - name: Verify docker-git CLI starts + run: bun ./packages/app/dist/src/docker-git/main.js --help + - name: Verify session sync CLI starts + run: bun ./packages/docker-git-session-sync/dist/docker-git-session-sync.js --help + - name: Prepare package artifacts directory + run: | + node -e "require('node:fs').mkdirSync('artifacts', { recursive: true })" + - name: Pack docker-git package + working-directory: packages/app + run: bun pm pack --quiet --ignore-scripts --destination ../../artifacts + - name: Pack session sync package + working-directory: packages/docker-git-session-sync + run: bun pm pack --quiet --ignore-scripts --destination ../../artifacts + - name: Upload final build artifacts + uses: actions/upload-artifact@v7 + with: + name: final-build-${{ matrix.os }} + path: artifacts/*.tgz diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index f8bf83ed..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-12T09:48:40.302Z for PR creation at branch issue-278-d92a50df7e27 for issue https://github.com/ProverCoderAI/docker-git/issues/278 \ No newline at end of file diff --git a/package.json b/package.json index 294c7e68..e8ea3862 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "changeset": "changeset", "changeset-publish": "bun -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", "changeset-version": "changeset version", - "clone": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js clone \"$@\"' --", - "open": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js open \"$@\"' --", - "docker-git": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js \"$@\"' --", + "clone": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js clone", + "open": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js open", + "docker-git": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js", "skiller:init": "git submodule update --init --checkout third_party/skiller-desktop-skills-manager && bun scripts/skiller-apply-docker-git-patches.mjs", "skiller:install": "bun install --cwd third_party/skiller-desktop-skills-manager --frozen-lockfile", "skiller:dev": "bun run --cwd third_party/skiller-desktop-skills-manager dev", @@ -36,7 +36,7 @@ "e2e:login-context": "bash scripts/e2e/login-context.sh", "e2e:runtime-volumes-ssh": "bash scripts/e2e/runtime-volumes-ssh.sh", "e2e:opencode-autoconnect": "bash scripts/e2e/opencode-autoconnect.sh", - "list": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js ps \"$@\"' --", + "list": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js ps", "dev": "bun run --cwd packages/app dev", "web:dev": "bun run --cwd packages/app dev:web", "web:build": "bun run --cwd packages/app build:web", @@ -47,7 +47,7 @@ "lint:effect": "bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect", "test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", "typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", - "start": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js \"$@\"' --" + "start": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js" }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", diff --git a/packages/app/package.json b/packages/app/package.json index 85adcd9b..bd2d7ba6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -28,12 +28,12 @@ "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "build:docker-git": "vite build --config vite.docker-git.config.ts", "check": "bun run typecheck", - "clone": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js clone \"$@\"' --", - "open": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js open \"$@\"' --", - "docker-git": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js \"$@\"' --", - "list": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js ps \"$@\"' --", + "clone": "bun run build:docker-git && bun dist/src/docker-git/main.js clone", + "open": "bun run build:docker-git && bun dist/src/docker-git/main.js open", + "docker-git": "bun run build:docker-git && bun dist/src/docker-git/main.js", + "list": "bun run build:docker-git && bun dist/src/docker-git/main.js ps", "preview:web": "vite preview --config vite.web.config.ts", - "start": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js \"$@\"' --", + "start": "bun run build:docker-git && bun dist/src/docker-git/main.js", "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "test": "bun run lint:tests && vitest run", "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", diff --git a/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts b/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts new file mode 100644 index 00000000..4d4596af --- /dev/null +++ b/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@effect/vitest" + +import rootPackage from "../../../../package.json" with { type: "json" } +import sessionSyncPackage from "../../../docker-git-session-sync/package.json" with { type: "json" } +import appPackage from "../../package.json" with { type: "json" } + +const launchScripts: ReadonlyArray> = [ + { packageName: "workspace", scriptName: "clone", script: rootPackage.scripts.clone }, + { packageName: "workspace", scriptName: "open", script: rootPackage.scripts.open }, + { packageName: "workspace", scriptName: "docker-git", script: rootPackage.scripts["docker-git"] }, + { packageName: "workspace", scriptName: "list", script: rootPackage.scripts.list }, + { packageName: "workspace", scriptName: "start", script: rootPackage.scripts.start }, + { packageName: "@prover-coder-ai/docker-git", scriptName: "clone", script: appPackage.scripts.clone }, + { packageName: "@prover-coder-ai/docker-git", scriptName: "open", script: appPackage.scripts.open }, + { + packageName: "@prover-coder-ai/docker-git", + scriptName: "docker-git", + script: appPackage.scripts["docker-git"] + }, + { packageName: "@prover-coder-ai/docker-git", scriptName: "list", script: appPackage.scripts.list }, + { packageName: "@prover-coder-ai/docker-git", scriptName: "start", script: appPackage.scripts.start } +] + +describe("package scripts cross-platform contract", () => { + it("keeps user-facing launch scripts independent from bash", () => { + for (const entry of launchScripts) { + expect(entry.script, `${entry.packageName}:${entry.scriptName}`).not.toMatch(/\bbash(?:\.exe)?\b/u) + } + }) + + it("keeps final package build independent from raw chmod", () => { + expect(sessionSyncPackage.scripts.build).not.toMatch(/\bchmod\s+/u) + }) +}) diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 228ac33d..5b4b3725 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -10,7 +10,7 @@ "dist" ], "scripts": { - "build": "vite build && chmod +x dist/docker-git-session-sync.js", + "build": "vite build && bun ../../scripts/mark-executable.mjs dist/docker-git-session-sync.js", "check": "bun run typecheck", "prepack": "bun run build", "test": "vitest run --passWithNoTests", diff --git a/scripts/mark-executable.mjs b/scripts/mark-executable.mjs new file mode 100644 index 00000000..d7a3befa --- /dev/null +++ b/scripts/mark-executable.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env bun + +import { chmodSync } from "node:fs" +import { resolve } from "node:path" + +// CHANGE: centralize executable-bit handling for generated CLI files. +// WHY: POSIX chmod is not available on Windows, while Linux/macOS package builds require executable bins. +// QUOTE(TZ): "run conveniently on Windows and Linux" +// REF: issue-278 +// SOURCE: n/a +// FORMAT THEOREM: forall p in Paths: platform=win32 -> no_posix_chmod(p), platform!=win32 -> executable(p) +// PURITY: SHELL +// EFFECT: filesystem metadata update +// INVARIANT: missing target argument exits non-zero; Windows builds do not invoke POSIX chmod. +// COMPLEXITY: O(1)/O(1) +const target = process.argv[2] + +if (target === undefined || target.length === 0) { + process.stderr.write("Usage: mark-executable \n") + process.exitCode = 1 +} else if (process.platform !== "win32") { + chmodSync(resolve(process.cwd(), target), 0o755) +} From a872a79230edcea7405200a732154568f2051d81 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 12 May 2026 11:09:16 +0000 Subject: [PATCH 3/4] fix(ci): bound clone cache mirror refresh refs --- .../app/src/lib/core/templates-entrypoint/tasks.ts | 13 ++++++++++++- packages/lib/src/core/templates-entrypoint/tasks.ts | 13 ++++++++++++- packages/lib/tests/core/templates.test.ts | 11 +++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/app/src/lib/core/templates-entrypoint/tasks.ts b/packages/app/src/lib/core/templates-entrypoint/tasks.ts index d6264455..1889eb05 100644 --- a/packages/app/src/lib/core/templates-entrypoint/tasks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/tasks.ts @@ -117,6 +117,17 @@ const renderCloneAuthRepoUrl = (): string => AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${RESOLVED_GIT_AUTH_USER}:\${RESOLVED_GIT_AUTH_TOKEN}@#")" fi` +// CHANGE: restrict clone-cache mirror refresh to branch and tag refs +// WHY: broad refs include hosted forge PR refs and make cache reuse proportional to every remote ref +// QUOTE(ТЗ): "Для тестов можно реализовать CI/CD workflow для Linux, MAC, Windows" +// REF: issue-278-ci-check-clone-cache +// SOURCE: n/a +// FORMAT THEOREM: forall r in refreshedRefs: r in refs/heads/* union refs/tags/* +// PURITY: CORE +// INVARIANT: clone-cache refresh never requests refs/pull/* or refs/merge-requests/* +// COMPLEXITY: O(|heads| + |tags|) +const cloneCacheRefreshRefspecs = "'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'" + const renderCloneCacheInit = (config: TemplateConfig): string => ` CLONE_CACHE_ARGS="" CACHE_REPO_DIR="" @@ -135,7 +146,7 @@ const renderCloneCacheInit = (config: TemplateConfig): string => chown 1000:1000 "$CACHE_ROOT" || true if [[ -d "$CACHE_REPO_DIR" ]]; then if su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' rev-parse --is-bare-repository >/dev/null 2>&1"; then - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' '+refs/*:refs/*'"; then + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then echo "[clone-cache] mirror refresh failed for $REPO_URL" fi CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate" diff --git a/packages/lib/src/core/templates-entrypoint/tasks.ts b/packages/lib/src/core/templates-entrypoint/tasks.ts index d6264455..1889eb05 100644 --- a/packages/lib/src/core/templates-entrypoint/tasks.ts +++ b/packages/lib/src/core/templates-entrypoint/tasks.ts @@ -117,6 +117,17 @@ const renderCloneAuthRepoUrl = (): string => AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${RESOLVED_GIT_AUTH_USER}:\${RESOLVED_GIT_AUTH_TOKEN}@#")" fi` +// CHANGE: restrict clone-cache mirror refresh to branch and tag refs +// WHY: broad refs include hosted forge PR refs and make cache reuse proportional to every remote ref +// QUOTE(ТЗ): "Для тестов можно реализовать CI/CD workflow для Linux, MAC, Windows" +// REF: issue-278-ci-check-clone-cache +// SOURCE: n/a +// FORMAT THEOREM: forall r in refreshedRefs: r in refs/heads/* union refs/tags/* +// PURITY: CORE +// INVARIANT: clone-cache refresh never requests refs/pull/* or refs/merge-requests/* +// COMPLEXITY: O(|heads| + |tags|) +const cloneCacheRefreshRefspecs = "'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'" + const renderCloneCacheInit = (config: TemplateConfig): string => ` CLONE_CACHE_ARGS="" CACHE_REPO_DIR="" @@ -135,7 +146,7 @@ const renderCloneCacheInit = (config: TemplateConfig): string => chown 1000:1000 "$CACHE_ROOT" || true if [[ -d "$CACHE_REPO_DIR" ]]; then if su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' rev-parse --is-bare-repository >/dev/null 2>&1"; then - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' '+refs/*:refs/*'"; then + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then echo "[clone-cache] mirror refresh failed for $REPO_URL" fi CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate" diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 4de72f9e..2092a1ba 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -74,6 +74,17 @@ describe("renderDockerfile", () => { }) }) +describe("renderEntrypoint clone cache", () => { + it("refreshes mirrors without broad remote refs", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig()) + + expect(entrypoint).toContain("git --git-dir '$CACHE_REPO_DIR' fetch") + expect(entrypoint).toContain("'+refs/heads/*:refs/heads/*'") + expect(entrypoint).toContain("'+refs/tags/*:refs/tags/*'") + expect(entrypoint).not.toContain("'+refs/*:refs/*'") + }) +}) + describe("renderEntrypointGitHooks", () => { it("installs pre-push protection checks and a global git post-push runtime", () => { const hooks = renderEntrypointGitHooks() From 7cf5e2228ebbbbffca7e889fc33bcf24ef73a7fc Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 13 May 2026 12:49:44 +0000 Subject: [PATCH 4/4] ci(final-build): verify browser clone flow --- .github/workflows/final-build.yml | 5 + bun.lock | 5 +- packages/app/package.json | 1 + .../docker-git/actions-project-create.test.ts | 137 +++++++++++++++ .../tests/docker-git/app-ready-create.test.ts | 45 ++++- .../package-scripts-cross-platform.test.ts | 9 +- packages/lib/tests/core/templates.test.ts | 2 + scripts/final-build/browser-web-smoke.mjs | 158 ++++++++++++++++++ 8 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 packages/app/tests/docker-git/actions-project-create.test.ts create mode 100644 scripts/final-build/browser-web-smoke.mjs diff --git a/.github/workflows/final-build.yml b/.github/workflows/final-build.yml index 70e931f7..ce24f93b 100644 --- a/.github/workflows/final-build.yml +++ b/.github/workflows/final-build.yml @@ -30,6 +30,11 @@ jobs: run: bun ./packages/app/dist/src/docker-git/main.js --help - name: Verify session sync CLI starts run: bun ./packages/docker-git-session-sync/dist/docker-git-session-sync.js --help + - name: Verify browser UI and menu clone smoke checks + run: | + bun run --cwd packages/app build:web + bun scripts/final-build/browser-web-smoke.mjs + bun run --cwd packages/app vitest run tests/docker-git/browser-frontend.test.ts tests/docker-git/app-ready-create.test.ts tests/docker-git/actions-project-create.test.ts - name: Prepare package artifacts directory run: | node -e "require('node:fs').mkdirSync('artifacts', { recursive: true })" diff --git a/bun.lock b/bun.lock index e0e2c218..d11dcc11 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.1.1", + "version": "1.1.5", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -95,6 +95,7 @@ "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", "eslint-plugin-unicorn": "^64.0.0", + "fast-check": "3.23.2", "globals": "^17.6.0", "jscpd": "^4.1.1", "typescript": "^6.0.3", @@ -106,7 +107,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.5", + "version": "1.0.8", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, diff --git a/packages/app/package.json b/packages/app/package.json index eed3f938..3415cc1a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -114,6 +114,7 @@ "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", "eslint-plugin-unicorn": "^64.0.0", + "fast-check": "3.23.2", "globals": "^17.6.0", "jscpd": "^4.1.1", "typescript": "^6.0.3", diff --git a/packages/app/tests/docker-git/actions-project-create.test.ts b/packages/app/tests/docker-git/actions-project-create.test.ts new file mode 100644 index 00000000..2c67470f --- /dev/null +++ b/packages/app/tests/docker-git/actions-project-create.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { beforeEach, vi } from "vitest" + +import type { CreateInputs } from "../../src/docker-git/menu-types.js" +import { submitCreateInputs } from "../../src/web/actions-project-create.js" +import type { ApiEvent, loadProjectDetails, ProjectDetails, startCreateProject } from "../../src/web/api.js" +import type { openProjectEventStream } from "../../src/web/project-events.js" +import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" + +const eventStreamCloseMock = vi.hoisted(() => vi.fn<() => void>()) +const loadProjectDetailsMock = vi.hoisted(() => vi.fn()) +const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) +const startCreateProjectMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/api.js", () => ({ + loadProjectDetails: loadProjectDetailsMock, + startCreateProject: startCreateProjectMock +})) + +vi.mock("../../src/web/project-events.js", () => ({ + openProjectEventStream: openProjectEventStreamMock +})) + +const createInputConfig = { + cpuLimit: "75%", + enableMcpPlaywright: true, + force: false, + forceEnv: false, + gpu: "none", + outDir: "/home/dev/.docker-git/octocat/Hello-World", + ramLimit: "1g", + repoRef: "main", + repoUrl: "https://github.com/octocat/Hello-World.git" +} satisfies Omit + +const createInputs: CreateInputs = { + ...createInputConfig, + runUp: true +} + +const expectedCreateDraft = { + ...createInputConfig, + up: createInputs.runUp +} + +const project = { + authorizedKeysExists: true, + authorizedKeysPath: "/home/dev/.docker-git/octocat/Hello-World/.ssh/authorized_keys", + clonedOnHostname: "runner", + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", + codexHome: "/home/dev/.docker-git/.orch/codex", + containerName: "docker-git-octocat-hello-world", + displayName: "octocat/Hello-World", + envGlobalPath: "/home/dev/.docker-git/.orch/env/global.env", + envProjectPath: "/home/dev/.docker-git/octocat/Hello-World/.orch/env/project.env", + gpu: "none", + id: "project-1", + projectDir: "/home/dev/.docker-git/octocat/Hello-World", + projectKey: "octocat/Hello-World", + repoRef: "main", + repoUrl: "https://github.com/octocat/Hello-World.git", + serviceName: "app", + sshCommand: "ssh -p 2244 dev@127.0.0.1", + sshPort: 2244, + sshSessions: 0, + sshUser: "dev", + startedAtEpochMs: 1_777_000_000_000, + startedAtIso: "2026-05-13T00:00:00.000Z", + status: "running", + statusLabel: "running", + targetDir: "/home/dev/project" +} satisfies ProjectDetails + +const projectCreatedEvent: ApiEvent = { + at: "2026-05-13T00:00:01.000Z", + payload: { + project, + projectId: project.id + }, + projectId: project.id, + seq: 8, + type: "project.created" +} + +const readCreateEventHandler = () => { + const handler = openProjectEventStreamMock.mock.calls[0]?.[1]?.onEvent + if (handler === undefined) { + throw new Error("missing create event handler") + } + return handler +} + +describe("browser create project action", () => { + beforeEach(() => { + eventStreamCloseMock.mockReset() + loadProjectDetailsMock.mockReset() + openProjectEventStreamMock.mockReset() + startCreateProjectMock.mockReset() + startCreateProjectMock.mockImplementation(() => + Effect.succeed({ + accepted: true, + cursor: 7, + projectId: project.id + }) + ) + openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) + }) + + it.effect("clones a project through the browser menu create flow", () => + Effect.gen(function*(_) { + const { context, output, reloadDashboard, setMessage } = makeBrowserActionContext() + + submitCreateInputs(createInputs, context) + + yield* _(waitForAssertion(() => { + expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) + })) + readCreateEventHandler()(projectCreatedEvent) + + yield* _(waitForAssertion(() => { + expect(context.setSelectedProject).toHaveBeenCalledWith(project) + })) + + expect(startCreateProjectMock).toHaveBeenCalledWith(expectedCreateDraft) + expect(openProjectEventStreamMock).toHaveBeenCalledWith(project.id, expect.objectContaining({ initialCursor: 7 })) + expect(eventStreamCloseMock).toHaveBeenCalledTimes(1) + expect(loadProjectDetailsMock).not.toHaveBeenCalled() + expect(reloadDashboard).toHaveBeenCalledTimes(1) + expect(context.setSelectedProjectId).toHaveBeenCalledWith(project.id) + expect(context.setSelectedMenuIndex).toHaveBeenCalledWith(1) + expect(setMessage).toHaveBeenLastCalledWith("Created octocat/Hello-World.") + expect(output()).toContain("[create] Project creation requested") + expect(output()).toContain("[create] Project accepted: project-1") + expect(output()).toContain("[create] Project created") + })) +}) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 80fc7900..ab497e73 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -1,15 +1,23 @@ import type { Dispatch, SetStateAction } from "react" -import { describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { type CreateFlowView, createInitialFlowView, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" +import type { CreateInputs } from "../../src/docker-git/menu-types.js" +import type { submitCreateInputs } from "../../src/web/actions-projects.js" import type { GithubAuthStatus } from "../../src/web/api.js" import { submitCreateView } from "../../src/web/app-ready-create.js" import { makeBrowserActionContext } from "./browser-action-context-fixture.js" +const submitCreateInputsMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/actions-projects.js", () => ({ + submitCreateInputs: submitCreateInputsMock +})) + const validGithubStatus: GithubAuthStatus = { summary: "valid", tokens: [{ key: "default", label: "default", login: "octocat", status: "valid" }] @@ -30,15 +38,20 @@ const requireCreateViewValue = ( return value } -const submitCreateBuffer = (buffer: string) => { +const submitCreateBuffer = ( + buffer: string, + options: { readonly quickCreate?: boolean } = {} +) => { const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const quickCreate = options.quickCreate === undefined ? {} : { quickCreate: options.quickCreate } submitCreateView({ context, controllerCwd: "/workspace", createView: createInitialFlowView(buffer), projectsRoot: "/home/dev/.docker-git", + ...quickCreate, setCreateView }) @@ -46,6 +59,10 @@ const submitCreateBuffer = (buffer: string) => { } describe("app-ready-create", () => { + beforeEach(() => { + submitCreateInputsMock.mockReset() + }) + it("advances to the next create field on Enter for a repo URL", () => { const { context, setCreateViewSpy } = submitCreateBuffer("https://github.com/org/repo/tree/feature-x --force") @@ -77,4 +94,28 @@ describe("app-ready-create", () => { expect(setCreateViewSpy).not.toHaveBeenCalled() expect(context.setMessage).toHaveBeenCalledWith("Missing value for option: --bogus") }) + + it("submits a quick create clone from the Create menu", () => { + const { setCreateViewSpy } = submitCreateBuffer( + "https://github.com/octocat/Hello-World/tree/feature-x", + { quickCreate: true } + ) + + expect(submitCreateInputsMock).toHaveBeenCalledTimes(1) + expect(submitCreateInputsMock.mock.calls[0]?.[0]).toEqual( + { + cpuLimit: "", + enableMcpPlaywright: false, + force: false, + forceEnv: false, + gpu: "none", + outDir: "/home/dev/.docker-git/octocat/hello-world", + ramLimit: "", + repoRef: "feature-x", + repoUrl: "https://github.com/octocat/Hello-World/tree/feature-x", + runUp: true + } satisfies CreateInputs + ) + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual(createInitialFlowView()) + }) }) diff --git a/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts b/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts index 4d4596af..e73993d6 100644 --- a/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts +++ b/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest" +import * as fc from "fast-check" import rootPackage from "../../../../package.json" with { type: "json" } import sessionSyncPackage from "../../../docker-git-session-sync/package.json" with { type: "json" } @@ -23,12 +24,14 @@ const launchScripts: ReadonlyArray { it("keeps user-facing launch scripts independent from bash", () => { - for (const entry of launchScripts) { + fc.assert(fc.property(fc.constantFrom(...launchScripts), (entry) => { expect(entry.script, `${entry.packageName}:${entry.scriptName}`).not.toMatch(/\bbash(?:\.exe)?\b/u) - } + })) }) it("keeps final package build independent from raw chmod", () => { - expect(sessionSyncPackage.scripts.build).not.toMatch(/\bchmod\s+/u) + fc.assert(fc.property(fc.constant(sessionSyncPackage.scripts.build), (script) => { + expect(script).not.toMatch(/\bchmod\s+/u) + })) }) }) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index d584989a..e988469f 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -89,6 +89,8 @@ describe("renderEntrypoint clone cache", () => { expect(entrypoint).toContain("'+refs/heads/*:refs/heads/*'") expect(entrypoint).toContain("'+refs/tags/*:refs/tags/*'") expect(entrypoint).not.toContain("'+refs/*:refs/*'") + expect(entrypoint).not.toContain("'+refs/pull/*:refs/pull/*'") + expect(entrypoint).not.toContain("'+refs/merge-requests/*:refs/merge-requests/*'") }) }) diff --git a/scripts/final-build/browser-web-smoke.mjs b/scripts/final-build/browser-web-smoke.mjs new file mode 100644 index 00000000..85521919 --- /dev/null +++ b/scripts/final-build/browser-web-smoke.mjs @@ -0,0 +1,158 @@ +import { spawn } from "node:child_process" +import { createServer } from "node:http" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)) +const requestTimeoutMs = 5000 +const startupTimeoutMs = 15000 + +const listen = (server) => + new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + server.off("error", reject) + resolve(server.address().port) + }) + }) + +const closeServer = (server) => + new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) { + resolve() + return + } + reject(error) + }) + }) + +const delay = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +const fetchText = async (url) => { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, requestTimeoutMs) + try { + const response = await fetch(url, { signal: controller.signal }) + const body = await response.text() + return { body, status: response.status } + } finally { + clearTimeout(timeout) + } +} + +const waitForText = async (url, predicate) => { + const startedAt = Date.now() + let lastError = null + while (Date.now() - startedAt < startupTimeoutMs) { + try { + const response = await fetchText(url) + if (predicate(response)) { + return response + } + lastError = new Error(`Unexpected response ${response.status} from ${url}: ${response.body.slice(0, 160)}`) + } catch (error) { + lastError = error + } + await delay(250) + } + throw lastError ?? new Error(`Timed out waiting for ${url}`) +} + +const createApiServer = () => + createServer((request, response) => { + if (request.url === "/health") { + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }) + response.end(JSON.stringify({ + cwd: "/tmp/docker-git-final-build-smoke", + ok: true, + projectsRoot: "/tmp/docker-git-final-build-smoke/projects", + revision: "final-build-smoke" + })) + return + } + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }) + response.end("not found") + }) + +const waitForExit = (child) => + new Promise((resolve) => { + child.once("exit", (code, signal) => { + resolve({ code, signal }) + }) + }) + +const terminate = async (child) => { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + child.kill() + const result = await Promise.race([ + waitForExit(child), + delay(3000).then(() => null) + ]) + if (result === null) { + child.kill("SIGKILL") + await waitForExit(child) + } +} + +const main = async () => { + const apiServer = createApiServer() + const apiPort = await listen(apiServer) + const webPortServer = createServer() + const webPort = await listen(webPortServer) + await closeServer(webPortServer) + + const statePath = join(tmpdir(), `docker-git-web-smoke-${process.pid}.json`) + const child = spawn(process.execPath, ["packages/app/scripts/serve-dist-web.mjs"], { + cwd: repoRoot, + env: { + ...process.env, + DOCKER_GIT_API_URL: `http://127.0.0.1:${apiPort}`, + DOCKER_GIT_WEB_HOST: "127.0.0.1", + DOCKER_GIT_WEB_PORT: String(webPort), + DOCKER_GIT_WEB_REVISION: "final-build-smoke", + DOCKER_GIT_WEB_STATE_PATH: statePath + }, + stdio: ["ignore", "pipe", "pipe"] + }) + + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk) => { + stdout += chunk + }) + child.stderr.on("data", (chunk) => { + stderr += chunk + }) + + try { + await waitForText( + `http://127.0.0.1:${webPort}/`, + ({ body, status }) => status === 200 && body.includes("docker-git browser") + ) + await waitForText( + `http://127.0.0.1:${webPort}/api/health`, + ({ body, status }) => status === 200 && body.includes("\"ok\":true") + ) + console.log("browser web smoke passed") + } catch (error) { + console.error(stdout) + console.error(stderr) + throw error + } finally { + await terminate(child) + await closeServer(apiServer) + } +} + +await main()