Skip to content
Merged
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
8 changes: 7 additions & 1 deletion app/ml/tests/test_eta_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,13 @@ def test_hot_reload_detects_version_change(self) -> None:
service = ETAService(settings)
service._model = MagicMock()
service._model_version = 1
service._last_reload_check = 0.0 # Force check
# Force a check: place the last-check far enough in the past that the
# reload interval has elapsed regardless of the absolute monotonic clock.
# (0.0 only works when time.monotonic() already exceeds the interval,
# which fails on freshly-booted CI runners where monotonic() is small.)
service._last_reload_check = (
time.monotonic() - settings.eta_model_reload_interval_seconds - 1
)

new_model = MagicMock()
new_metadata = _make_metadata(version=2)
Expand Down
30 changes: 30 additions & 0 deletions cms/apps/web/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,33 @@ export async function hasDataTable(page: Page) {
const table = page.getByRole("table");
return await table.isVisible();
}

/**
* Wait for a data table to finish loading, then report whether it has real data rows.
*
* While loading, the tables render skeleton placeholders inside `<TableRow>`s, which
* expose `role="row"` just like real rows. Row-based waits therefore race the skeletons
* and can treat a placeholder as data — then, once loading resolves to the empty state,
* that row vanishes and any "click the row" assertion times out on a detached element.
* Waiting for the skeletons to detach first removes the race, so the caller sees a
* settled table: either real data rows or the empty state.
*
* @returns true when real data rows are present, false when the table is empty.
*/
export async function waitForTableSettled(page: Page): Promise<boolean> {
// Let the loading skeletons detach (resolves immediately if none are shown).
await page
.locator('table [data-slot="skeleton"]')
.last()
.waitFor({ state: "detached", timeout: 10000 })
.catch(() => {});

const emptyState = page.getByText(/no.*found|nav.*atrast/i);
const firstRow = page.getByRole("table").getByRole("row").nth(1);
await Promise.race([
firstRow.waitFor({ state: "visible", timeout: 5000 }),
emptyState.waitFor({ state: "visible", timeout: 5000 }),
]).catch(() => {});

return (await firstRow.isVisible()) && !(await emptyState.isVisible());
}
43 changes: 12 additions & 31 deletions cms/apps/web/e2e/schedules.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { waitForDataOrEmpty } from "./helpers";
import { waitForDataOrEmpty, waitForTableSettled } from "./helpers";

test.describe("Schedules page", () => {
test.beforeEach(async ({ page }) => {
Expand Down Expand Up @@ -39,16 +39,8 @@ test.describe("Schedules page", () => {

test("clicking row opens detail sheet", async ({ page }) => {
await page.getByRole("tab", { name: /calendars|kalendāri/i }).click();
// Wait for data or empty state
const emptyState = page.getByText(/no.*found|nav.*atrast/i);
const table = page.getByRole("table");
const firstRow = table.getByRole("row").nth(1);
await Promise.race([
firstRow.waitFor({ state: "visible", timeout: 5000 }),
emptyState.waitFor({ state: "visible", timeout: 5000 }),
]).catch(() => {});
if (await emptyState.isVisible()) return; // No data — skip
if (!(await firstRow.isVisible())) return;
if (!(await waitForTableSettled(page))) return; // No data — skip
const firstRow = page.getByRole("table").getByRole("row").nth(1);
// Retry click+dialog check to handle React rendering timing under parallel load
await expect(async () => {
await firstRow.getByRole("cell").first().click();
Expand All @@ -72,16 +64,8 @@ test.describe("Schedules page", () => {

test("calendar detail shows operating days", async ({ page }) => {
await page.getByRole("tab", { name: /calendars|kalendāri/i }).click();
// Wait for data or empty state
const emptyState = page.getByText(/no.*found|nav.*atrast/i);
const table = page.getByRole("table");
const firstRow = table.getByRole("row").nth(1);
await Promise.race([
firstRow.waitFor({ state: "visible", timeout: 5000 }),
emptyState.waitFor({ state: "visible", timeout: 5000 }),
]).catch(() => {});
if (await emptyState.isVisible()) return; // No data — skip
if (!(await firstRow.isVisible())) return;
if (!(await waitForTableSettled(page))) return; // No data — skip
const firstRow = page.getByRole("table").getByRole("row").nth(1);
// Retry click+dialog check to handle React rendering timing under parallel load
await expect(async () => {
await firstRow.getByRole("cell").first().click();
Expand Down Expand Up @@ -125,16 +109,13 @@ test.describe("Schedules page", () => {

test("clicking trip row opens detail", async ({ page }) => {
await page.getByRole("tab", { name: /trips|reisi/i }).click();
await waitForDataOrEmpty(page);
const table = page.getByRole("table");
const firstRow = table.getByRole("row").nth(1);
if (await firstRow.isVisible()) {
// Retry click+dialog check to handle React rendering timing under parallel load
await expect(async () => {
await firstRow.getByRole("cell").first().click();
await expect(page.getByRole("dialog")).toBeVisible();
}).toPass({ timeout: 10000 });
}
if (!(await waitForTableSettled(page))) return; // No data — skip
const firstRow = page.getByRole("table").getByRole("row").nth(1);
// Retry click+dialog check to handle React rendering timing under parallel load
await expect(async () => {
await firstRow.getByRole("cell").first().click();
await expect(page.getByRole("dialog")).toBeVisible();
}).toPass({ timeout: 10000 });
});

test("create trip button opens form", async ({ page }) => {
Expand Down
19 changes: 13 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ dependencies = [
"bcrypt>=5.0.0",
"email-validator>=2.0.0",
"cachetools>=5.5.0",
"fastapi>=0.120.1",
"fastapi>=0.139.0",
"google-genai>=1.0.0",
"gunicorn>=23.0.0",
"gtfs-realtime-bindings>=1.0.0",
Expand All @@ -21,7 +21,7 @@ dependencies = [
"openpyxl>=3.1.5",
"pgvector>=0.4.2",
"pillow>=12.2.0",
"pydantic-ai>=1.58.0",
"pydantic-ai>=1.102.0,<2", # CVE-2026-46678, CVE-2026-48782 (SSRF metadata bypass); hold on v1 line pending 2.x migration
"pydantic-settings>=2.11.0",
"qrcode>=8.0",
"pymupdf>=1.27.1",
Expand Down Expand Up @@ -80,16 +80,23 @@ ner = [
# These come via fastmcp/mcp/python-jose/pydantic-ai/openai — we pin minimums
# via override-dependencies so uv picks up CVE fixes during resolution.
override-dependencies = [
"aiohttp>=3.13.4", # CVE-2026-22815, CVE-2026-34513..34525 (10 CVEs: DoS, header injection, auth leak)
"aiohttp>=3.14.1", # PYSEC-2026-237, CVE-2026-34993/47265/50269/54273..54280 (SNI bypass, cookie leak, DoS)
"authlib>=1.6.11", # CVE-2026-27962, CVE-2026-28490, GHSA-jj8c-mmj3-mmgv (JWK header injection, crit bypass, OAuth cache CSRF)
"fastmcp>=3.2.0", # CVE-2025-64340, CVE-2026-27124 (Windows CLI command injection, OAuthProxy Confused Deputy)
"cryptography>=46.0.7", # CVE-2026-34073, CVE-2026-39892 (name constraint bypass, buffer overflow)
"cryptography>=48.0.1", # GHSA-537c-gmf6-5ccf (bundled OpenSSL 20260609 advisory) + CVE-2026-34073/39892
"ecdsa>=0.19.2", # CVE-2026-33936
"idna>=3.15", # PYSEC-2026-215 (idna.encode DoS on long input)
"joserfc>=1.6.8", # CVE-2026-49852 (empty HMAC key auth bypass), CVE-2026-48990 (RFC7797 size-limit bypass)
"mako>=1.3.12", # CVE-2026-44307 (Windows backslash path traversal)
"msgpack>=1.2.1", # GHSA-6v7p-g79w-8964 (SEGV on Unpacker reuse after error)
"pip>=26.1.2", # PYSEC-2026-196, CVE-2026-6357 (entry-point path escape, self-update import)
"pyasn1>=0.6.3", # CVE-2026-30922 (DoS via deep ASN.1 nesting)
"pygments>=2.20.0", # CVE-2026-4539 (ReDoS in AdlLexer)
"pyjwt>=2.12.0", # CVE-2026-32597 (unvalidated crit header)
"python-multipart>=0.0.26", # CVE-2026-40347 (multipart preamble/epilogue DoS)
"pyjwt>=2.13.0", # PYSEC-2026-175..179 (JWK/HMAC confusion, jku SSRF, detached-JWS DoS) + CVE-2026-32597
"python-multipart>=0.0.31", # CVE-2026-53538/53539/53540 (param smuggling, O(n^2) DoS, negative Content-Length) + CVE-2026-40347
"requests>=2.33.0", # CVE-2026-25645 (extract_zipped_paths tmpfile hijack)
"starlette>=1.3.1", # PYSEC-2026-161/248/249, CVE-2026-48817/48818 (Host-header path, url smuggling, form limits, HTTPEndpoint, StaticFiles UNC)
"urllib3>=2.7.0", # PYSEC-2026-141/142 (redirect header leak, streaming decompression DoS)
]

[tool.ruff]
Expand Down
Loading
Loading