diff --git a/.github/workflows/product-check.yml b/.github/workflows/product-check.yml index cf54074..b5d5053 100644 --- a/.github/workflows/product-check.yml +++ b/.github/workflows/product-check.yml @@ -8,19 +8,22 @@ on: permissions: contents: read +env: + PYTHONUTF8: "1" + THOTH_WORKSPACE: .thoth-ci + THOTH_IRIS_RESULT_FIXTURE: .thoth-ci/no-private-result.json + jobs: - product-check: - # The documented quick start supports Windows; Linux remains unverified. + checks: + # Fast public checks (about ten minutes). The full Python suite takes hours on a hosted + # runner, so maintainers run it locally before merging. runs-on: windows-latest - timeout-minutes: 180 + timeout-minutes: 60 defaults: run: shell: pwsh env: - PYTHONUTF8: "1" PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.thoth-test-browsers - THOTH_WORKSPACE: .thoth-ci - THOTH_IRIS_RESULT_FIXTURE: .thoth-ci/no-private-result.json steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 @@ -45,8 +48,6 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } .\.venv\Scripts\python.exe -m playwright install --only-shell chromium if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Python product tests - run: .\.venv\Scripts\python.exe -m pytest - name: Python lint run: .\.venv\Scripts\python.exe -m ruff check src apps tests migrations conftest.py scripts/pytest_environment.py - name: Runtime architecture diff --git a/apps/web/src/components/FirstRunSetup.test.tsx b/apps/web/src/components/FirstRunSetup.test.tsx index af4952f..0d12740 100644 --- a/apps/web/src/components/FirstRunSetup.test.tsx +++ b/apps/web/src/components/FirstRunSetup.test.tsx @@ -45,13 +45,13 @@ const fixture = vi.hoisted(() => ({ deferredStatus: null as null | (() => void), holdStatus: false, forbidNetwork: false, + // Requests the fake server has received and not yet answered; the tests wait for it to reach 0. + inflight: 0, })); vi.mock("../api/rpcClient", async (importOriginal) => { const original = await importOriginal(); - return { - ...original, - rpc: async (method: string, input: Record) => { + const answer = async (method: string, input: Record) => { fixture.calls.push({ method, input }); if (method === "workspace/ready") { if (fixture.readyError) throw new Error(fixture.readyError); @@ -103,7 +103,18 @@ vi.mock("../api/rpcClient", async (importOriginal) => { return { state: "SUCCEEDED", operation_id: "", value: fixture.ready }; } throw new Error(`unexpected method ${method}`); - }, + }; + return { + ...original, + rpc: async (method: string, input: Record) => { + // A response the test deliberately holds back is not waited for. + const held = (method === "workspace/ready" && fixture.deferReady) + || (method === "model/credential/login/status" && fixture.holdStatus) + || (method === "model/tooling/install" && fixture.installGate !== null); + if (held) return answer(method, input); + fixture.inflight += 1; + try { return await answer(method, input); } finally { fixture.inflight -= 1; } + }, }; }); @@ -186,9 +197,19 @@ it("keeps a normal MISSING setup state on the existing first-run path", async () expect(container.textContent).not.toContain("기존 작업 공간 설정을 확인하세요"); expect(fixture.calls.some(call => call.method === "workspace/setup/update")).toBe(false); }); +/** Lets the screen show what the fake server answered: waits until every request has been answered + * and no new one starts for several ticks in a row (not a fixed time, which a slow machine overruns; + * and not a single idle reading, because the screen sends the next request a moment after a response). */ async function flush() { await act(async () => { - await tick(); + let quiet = 0; + let seen = fixture.calls.length; + while (quiet < 3) { + await tick(); + const idle = fixture.inflight === 0 && fixture.calls.length === seen; + seen = fixture.calls.length; + quiet = idle ? quiet + 1 : 0; + } }); } @@ -238,6 +259,7 @@ afterEach(async () => { fixture.installGate = null; fixture.deferredStatus = null; fixture.holdStatus = false; + fixture.inflight = 0; fixture.forbidNetwork = false; vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -444,7 +466,7 @@ it("ignores a late xAI status response after the workspace changes", async () => fixture.holdStatus = true; await act(async () => { [...row.querySelectorAll("button")].find(button => button.textContent === "상태 직접 확인")!.click(); }); await flush(); - expect(fixture.deferredStatus).not.toBeNull(); + await vi.waitFor(() => expect(fixture.deferredStatus).not.toBeNull()); fixture.ready = { ...fixture.ready, workspace_id: WORKSPACE_B }; await act(async () => { await client.invalidateQueries({ queryKey: ["workspace-ready"] }); }); await flush(); @@ -496,8 +518,9 @@ it("stops xAI device polling when its Unix-seconds expiry passes", async () => { fixture.forbidNetwork = true; fixture.ready = localReady(); fixture.accounts = defaultAccounts().map((account) => account.provider === "xai" ? xaiAccount() : account); - fixture.registerResult = xaiStart(0.3); await mount(); + // The short expiry runs from the start response, so it is set after the slow mount, not before. + fixture.registerResult = xaiStart(0.3); const row = [...container.querySelectorAll(".first-run-provider")].find(item => item.textContent?.includes("xAI"))!; await act(async () => [...row.querySelectorAll("button")].find(button => button.textContent === "xAI로 로그인")!.click()); await flush(); await act(async () => { await new Promise(resolve => setTimeout(resolve, 1100)); }); @@ -791,7 +814,7 @@ it("ignores a late Codex OAuth status from the previous workspace", async () => fixture.holdStatus = true; await act(async () => [...row.querySelectorAll("button")].find(button => button.textContent === "상태 직접 확인")!.click()); await flush(); - expect(fixture.deferredStatus).not.toBeNull(); + await vi.waitFor(() => expect(fixture.deferredStatus).not.toBeNull()); fixture.ready = localReady({ workspace_id: WORKSPACE_B }); await act(async () => { await client.invalidateQueries({ queryKey: ["workspace-ready"] }); }); await flush(); diff --git a/tests/integration/test_model_execution_marks.py b/tests/integration/test_model_execution_marks.py index 4a35a6f..e33948d 100644 --- a/tests/integration/test_model_execution_marks.py +++ b/tests/integration/test_model_execution_marks.py @@ -1,4 +1,7 @@ -"""A real request that the provider refuses for the model or account marks that model; nothing else.""" +"""A real request that the provider refuses for the model or account marks that model. + +Nothing else is marked. +""" from __future__ import annotations diff --git a/tests/integration/test_scope_read_request_memo.py b/tests/integration/test_scope_read_request_memo.py index 8e70790..1f38889 100644 --- a/tests/integration/test_scope_read_request_memo.py +++ b/tests/integration/test_scope_read_request_memo.py @@ -1,4 +1,7 @@ -"""Inside one query the read-approval memo gives the same verdicts as no memo, with far fewer lookups.""" +"""Inside one query the read-approval memo gives the same verdicts as no memo. + +It does so with far fewer lookups. +""" from __future__ import annotations