fix(enterprise): ship enterprise package in prod image + S3, analytics, tracing, queue fixes (#342)

* fix(enterprise): ship enterprise pkg in prod image, full license features, tracing key fallback

docker/Dockerfile: COPY packages/enterprise manifest+src into the production stage.
Without it, apps/api's workspace link to @snapotter/enterprise dangles and every
import() throws (silently caught), so all 19 enterprise features failed closed
(enterprise.active=false) regardless of a valid license.

scripts/generate-license.mjs: sync PLAN_FEATURES with packages/enterprise/src/license.ts
so a --plan enterprise license unlocks all 19 features (was 8) and team unlocks 8.

apps/api/src/tracing.ts: accept SNAPOTTER_LICENSE_KEY as a fallback to LICENSE_KEY so
distributed_tracing activates with the same key as the rest of the app.

* fix(docker): keep scripts/bake-analytics.mjs in build context

.dockerignore excluded the whole scripts/ dir (PR #82, V1 hardening), but
docker/Dockerfile later added 'COPY scripts/bake-analytics.mjs' for the analytics
bake step. A clean production image build therefore fails with
'scripts/bake-analytics.mjs: not found'. The published image build is gated off in
CI so this latent break went unnoticed. Exclude scripts/* but re-include the one
file the Dockerfile needs.

* fix: S3 upload stream, analytics bake reaches API, dedupe retention field, reconcile orphan jobs

storage-s3.ts: wrap the upload AsyncIterable in Readable.from() so @aws-sdk/lib-storage
accepts it. STORAGE_MODE=s3 file uploads failed with 'Body Data is unsupported format'
for every tool because a bare async generator is not a Readable.

docker/Dockerfile: COPY the builder-baked analytics baked.ts into the API runtime stage.
The API re-copied the committed (off) baked.ts from the build context, so the
SNAPOTTER_ANALYTICS build arg had no effect on the API -- and since the SPA reads
/api/v1/config/analytics, analytics was off everywhere regardless of the arg.

settings-dialog.tsx: remove the duplicate tempFileMaxAgeHours control under Data
Retention; it bound the same setting key as the File Management control with a different
default, so editing either silently overwrote the other.

apps/api/src/index.ts: reconcile orphaned job rows (empty tool_id, never enqueued to
BullMQ) at boot so they don't sit in processing/queued forever and inflate the per-user
concurrent-job count and the upgrade-check in-flight gate.

* fix(web): style the SSO login buttons (they referenced undefined theme tokens)

The OIDC/SAML 'Sign in with <provider>' buttons used bg-secondary /
text-secondary-foreground, which the web theme never defines (it has primary,
background, foreground, muted, border, card, primary-subtle). Those classes resolved
to nothing, so the buttons rendered as bare unstyled text on the login page.

Restyle: the optional (non-enforced) buttons become white-card outline buttons with a
key icon and an orange hover tint, secondary to the primary Login button; the
SSO-enforced buttons become solid primary with the icon.

* fix: gate S3 behind license, custom-role enterprise perms, wire retention UI, cleanup

S3 is a licensed feature, but shipping packages/enterprise in every image removed the
implicit gate, so STORAGE_MODE=s3 worked without a license. Enforce
isFeatureEnabled('s3_storage') at boot and fail fast if unlicensed.

Custom roles can now be granted security:manage / compliance:manage / webhooks:manage
(roles.ts ALL_PERMISSIONS + the Roles UI) so admins can build least-privilege
compliance/security roles instead of only the built-in admin role.

retentionSweep now reads the jobsRetentionDays / auditRetentionDays DB settings the
System Settings UI writes (env vars become the fallback default), mirroring how the
temp-file sweep reads tempFileMaxAgeHours. Previously those two UI controls were no-ops.

Cleanup: drop the never-set snapotter_storage_bytes gauge and the unused
MAX_WORKSPACE_SIZE_GB env var; emit tool_client_error to PostHog from the web
ErrorBoundary (client crashes were not reaching analytics); add the Python
OpenTelemetry packages so the innermost sidecar.<script> span exports; fix the stale
'only local storage' line in the docs; delete two e2e-analytics specs that tested the
removed consent UI.

* fix(env): restore MAX_WORKSPACE_SIZE_GB default

security-auth-hardening.test.ts asserts env.MAX_WORKSPACE_SIZE_GB defaults to 10, so
the var is an intentional (tested) default, not dead code. Removing it in the cleanup
commit broke that unit test. Keep the declaration.
This commit is contained in:
SnapOtter
2026-06-24 17:27:59 +08:00
committed by GitHub
parent c2ae334c81
commit 8f4235d2c6
17 changed files with 147 additions and 195 deletions
+6 -1
View File
@@ -34,7 +34,12 @@ docs
.github
.husky
.releaserc.json
scripts
# Exclude scripts/ from the build context EXCEPT bake-analytics.mjs, which
# docker/Dockerfile COPYs to bake the analytics config. A blanket `scripts`
# ignore breaks the production image build (COPY scripts/bake-analytics.mjs ->
# "not found"); use dir/* + negation so the one needed file is re-included.
scripts/*
!scripts/bake-analytics.mjs
# IDE
.vscode
+37
View File
@@ -188,6 +188,27 @@ try {
// Enterprise package not available
}
// S3 storage is a licensed feature (s3_storage). packages/enterprise now ships in
// every image, so STORAGE_MODE=s3 would otherwise function without any license check.
// Enforce the gate at boot so an unlicensed deploy fails fast rather than silently
// writing data to S3 it isn't entitled to use.
if (env.STORAGE_MODE === "s3") {
let s3Licensed = false;
try {
const { isFeatureEnabled } = await import("@snapotter/enterprise");
s3Licensed = isFeatureEnabled("s3_storage");
} catch {
s3Licensed = false;
}
if (!s3Licensed) {
console.error(
"[FATAL] STORAGE_MODE=s3 requires a license that includes the s3_storage feature. " +
"Set a valid SNAPOTTER_LICENSE_KEY (team or enterprise plan) or use STORAGE_MODE=local.",
);
process.exit(1);
}
}
// Start the cooperative cancellation listener (Redis pub/sub)
await startCancelListener();
@@ -625,6 +646,22 @@ if (await shouldRunStartupCleanup()) {
// Start BullMQ worker pools (after route registration so the tool registry is full)
startWorkers();
// Reconcile orphaned job rows. A jobs row created without a tool_id/pool (e.g. an
// SSE-progress placeholder for a clientJobId whose client then disconnected) is
// never enqueued to BullMQ, so unlike a genuinely interrupted job (which BullMQ's
// stalled-detection requeues) it would sit in 'processing'/'queued' forever --
// inflating the per-user concurrent-job count and the upgrade-check in-flight gate.
// Only rows with an empty tool_id are touched, so real jobs are never affected.
void db
.execute(
sql`UPDATE jobs SET status = 'failed', error = '{"message":"Orphaned job reconciled at startup"}'::jsonb, completed_at = now() WHERE status IN ('processing','queued') AND (tool_id IS NULL OR tool_id = '')`,
)
.then((r) => {
const n = (r as { rowCount?: number }).rowCount ?? 0;
if (n > 0) app.log.info({ count: n }, "Reconciled orphaned job rows at startup");
})
.catch((err) => app.log.warn({ err }, "Orphaned-job reconciliation failed"));
// Warm the per-pool QueueEvents consumers so the first synchronous tool request
// after boot does not pay the lazy-connect cost (and cannot miss a fast job's
// completion event). Non-blocking: a slow/unreachable Redis must not stall boot;
+10 -4
View File
@@ -15,6 +15,7 @@ import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { getMaxAgeMs } from "../lib/cleanup.js";
import { deletePrefix, listJobDirs, type ObjectInfo } from "../lib/object-storage.js";
import { getSettingNumber } from "../lib/settings-helpers.js";
import { runAuditArchive } from "./audit-archive.js";
import { getQueue } from "./queues.js";
import { runSiemForward } from "./siem-forward.js";
@@ -275,15 +276,20 @@ async function retentionSweep(): Promise<void> {
WHERE u.legal_hold = true OR t.legal_hold = true
)`;
if (env.JOBS_RETENTION_DAYS > 0) {
// The Data Retention settings UI writes jobsRetentionDays / auditRetentionDays to
// the DB. Honor those (the env vars are the fallback default), mirroring how the
// temp-file sweep reads tempFileMaxAgeHours; otherwise the UI controls are no-ops.
const jobsRetentionDays = await getSettingNumber("jobsRetentionDays", env.JOBS_RETENTION_DAYS);
if (jobsRetentionDays > 0) {
await db.execute(
sql`DELETE FROM jobs
WHERE created_at < now() - ${env.JOBS_RETENTION_DAYS} * interval '1 day'
WHERE created_at < now() - ${jobsRetentionDays} * interval '1 day'
AND status IN ('completed', 'failed', 'canceled')
AND (user_id IS NULL OR user_id NOT IN ${heldUsersSubquery})`,
);
}
if (env.AUDIT_RETENTION_DAYS > 0) {
const auditRetentionDays = await getSettingNumber("auditRetentionDays", env.AUDIT_RETENTION_DAYS);
if (auditRetentionDays > 0) {
const tamperResult = await db
.select({ value: schema.settings.value })
.from(schema.settings)
@@ -296,7 +302,7 @@ async function retentionSweep(): Promise<void> {
if (!isTamperResistant) {
await db.execute(
sql`DELETE FROM audit_log
WHERE created_at < now() - ${env.AUDIT_RETENTION_DAYS} * interval '1 day'
WHERE created_at < now() - ${auditRetentionDays} * interval '1 day'
AND (actor_id IS NULL OR actor_id NOT IN ${heldUsersSubquery})`,
);
}
+1 -8
View File
@@ -5,7 +5,7 @@
* and a metricsText() function that appends live queue-depth gauges
* from BullMQ before returning the scrape payload.
*/
import { Counter, collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client";
import { Counter, collectDefaultMetrics, Histogram, Registry } from "prom-client";
import { perPoolCounts } from "../jobs/queues.js";
export const registry = new Registry();
@@ -34,13 +34,6 @@ export const requestDuration = new Histogram({
registers: [registry],
});
export const storageUsage = new Gauge({
name: "snapotter_storage_bytes",
help: "Storage usage in bytes",
labelNames: ["category"] as const,
registers: [registry],
});
export const authAttempts = new Counter({
name: "snapotter_auth_attempts_total",
help: "Authentication attempts",
+3
View File
@@ -22,6 +22,9 @@ const ALL_PERMISSIONS: Permission[] = [
"features:manage",
"system:health",
"audit:read",
"compliance:manage",
"webhooks:manage",
"security:manage",
];
const roleNameField = z
+4 -1
View File
@@ -79,7 +79,10 @@ const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (endpoint) {
try {
const enterprise = await import("@snapotter/enterprise");
const licenseKey = process.env.LICENSE_KEY ?? "";
// The rest of the app reads SNAPOTTER_LICENSE_KEY (env.ts / index.ts). Accept
// either here so distributed_tracing activates with the same key as every other
// enterprise feature; LICENSE_KEY kept as an override for preload-only setups.
const licenseKey = process.env.LICENSE_KEY ?? process.env.SNAPOTTER_LICENSE_KEY ?? "";
if (licenseKey) {
enterprise.initEnterprise(licenseKey);
}
+1 -1
View File
@@ -33,7 +33,7 @@ All configuration is done through environment variables. Every variable has a se
| Variable | Default | Description |
|---|---|---|
| `STORAGE_MODE` | `local` | `local` or `s3`. Only local storage is currently implemented. |
| `STORAGE_MODE` | `local` | `local` or `s3`. S3/MinIO requires a license with the s3_storage feature. |
| `DATABASE_URL` | `postgres://snapotter:snapotter@postgres:5432/snapotter` | PostgreSQL connection string. |
| `REDIS_URL` | `redis://redis:6379` | Redis connection string (used for BullMQ job queues). |
| `WORKSPACE_PATH` | `./tmp/workspace` | Directory for temporary files during processing. Cleaned up automatically. |
+4 -2
View File
@@ -1,4 +1,4 @@
import { en } from "@snapotter/shared";
import { ANALYTICS_EVENTS, en } from "@snapotter/shared";
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { Toaster, toast } from "sonner";
@@ -8,7 +8,7 @@ import { RouteAnnouncer } from "./components/common/route-announcer";
import { I18nProvider } from "./contexts/i18n-context";
import { useAuth } from "./hooks/use-auth";
import { useMobile } from "./hooks/use-mobile";
import { initAnalytics } from "./lib/analytics";
import { initAnalytics, track } from "./lib/analytics";
import { useAnalyticsStore } from "./stores/analytics-store";
// Lazy-load all pages so each page's JS (and its icons/deps) is only
@@ -48,6 +48,8 @@ class ErrorBoundary extends Component<
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Uncaught render error:", error, info.componentStack);
// Mirror the crash to PostHog (error class only, no PII). track() is best-effort.
track(ANALYTICS_EVENTS.TOOL_CLIENT_ERROR, { error_name: error.name });
import("@sentry/react")
.then((Sentry) => {
Sentry.captureException(error, {
@@ -690,20 +690,10 @@ function SystemSection() {
{t.settings.dataRetention.title}
</h4>
</div>
<SettingRow
label={t.settings.dataRetention.fileMaxAgeHours}
description={t.settings.dataRetention.fileMaxAgeHoursDesc}
>
<input
type="number"
value={settings.tempFileMaxAgeHours || "72"}
onChange={(e) => updateSetting("tempFileMaxAgeHours", e.target.value)}
aria-label={t.settings.dataRetention.fileMaxAgeHours}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={1}
max={8760}
/>
</SettingRow>
{/* NOTE: the temp-file TTL (tempFileMaxAgeHours) is configured once under
File Management above. A second control here bound the same key with a
different default, so editing either silently overwrote the other.
Data Retention keeps only the DB-row retention controls below. */}
<SettingRow
label={t.settings.dataRetention.jobsRetentionDays}
description={t.settings.dataRetention.jobsRetentionDaysDesc}
@@ -2600,6 +2590,10 @@ const PERMISSION_GROUPS = [
label: "System",
permissions: ["features:manage", "system:health", "audit:read"],
},
{
label: "Enterprise Administration",
permissions: ["security:manage", "compliance:manage", "webhooks:manage"],
},
];
function RolesSection() {
+15 -4
View File
@@ -1,3 +1,4 @@
import { KeyRound } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
@@ -236,16 +237,18 @@ export function LoginPage() {
{oidcEnabled && (
<a
href="/api/auth/oidc/login"
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors flex items-center justify-center gap-2"
className="w-full py-3 px-4 rounded-lg bg-primary text-primary-foreground font-medium shadow-sm hover:bg-primary-dark transition-colors flex items-center justify-center gap-2.5"
>
<KeyRound className="w-[18px] h-[18px]" aria-hidden="true" />
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
</a>
)}
{samlEnabled && (
<a
href="/api/auth/saml/login"
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors flex items-center justify-center gap-2"
className="w-full py-3 px-4 rounded-lg bg-primary text-primary-foreground font-medium shadow-sm hover:bg-primary-dark transition-colors flex items-center justify-center gap-2.5"
>
<KeyRound className="w-[18px] h-[18px]" aria-hidden="true" />
{format(t.auth.signInWith, { provider: samlProviderName || "SSO" })}
</a>
)}
@@ -386,16 +389,24 @@ export function LoginPage() {
{oidcEnabled && (
<a
href="/api/auth/oidc/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
className="group w-full py-3 px-4 rounded-lg border border-border bg-card text-foreground font-medium shadow-sm hover:border-primary hover:bg-primary-subtle transition-colors flex items-center justify-center gap-2.5"
>
<KeyRound
className="w-[18px] h-[18px] text-muted-foreground group-hover:text-primary transition-colors"
aria-hidden="true"
/>
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
</a>
)}
{samlEnabled && (
<a
href="/api/auth/saml/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2 mt-2"
className="group w-full mt-2 py-3 px-4 rounded-lg border border-border bg-card text-foreground font-medium shadow-sm hover:border-primary hover:bg-primary-subtle transition-colors flex items-center justify-center gap-2.5"
>
<KeyRound
className="w-[18px] h-[18px] text-muted-foreground group-hover:text-primary transition-colors"
aria-hidden="true"
/>
{format(t.auth.signInWith, { provider: samlProviderName || "SSO" })}
</a>
)}
+14
View File
@@ -322,6 +322,12 @@ COPY packages/image-engine/package.json packages/image-engine/tsconfig.json ./pa
COPY packages/media-engine/package.json packages/media-engine/tsconfig.json ./packages/media-engine/
COPY packages/doc-engine/package.json packages/doc-engine/tsconfig.json ./packages/doc-engine/
COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/
# packages/enterprise is required for ALL commercial features (license validation,
# SAML/SCIM/MFA gates, S3 storage, OTel tracing gate, GDPR/audit/SIEM routes).
# Without it, apps/api's `@snapotter/enterprise: workspace:*` link dangles and every
# `import("@snapotter/enterprise")` throws (silently caught) -> enterprise.active=false
# regardless of license. Manifest copied before install so pnpm wires the workspace link.
COPY packages/enterprise/package.json packages/enterprise/tsconfig.json ./packages/enterprise/
# pnpm patchedDependencies (package.json) needs the patch files present before
# install, or `pnpm install` aborts with ENOENT on the patch.
@@ -351,11 +357,19 @@ COPY apps/api/static ./apps/api/static
# Copy workspace packages source (referenced by API at runtime)
COPY packages/shared/src ./packages/shared/src
# The builder stage ran scripts/bake-analytics.mjs to bake the analytics config
# (driven by SNAPOTTER_ANALYTICS). The line above re-copies the committed baked.ts
# from the build context, which would clobber that bake and leave the API runtime
# with analytics permanently off -- so SNAPOTTER_ANALYTICS had no effect on the API
# (and, since the SPA reads /api/v1/config/analytics, no effect anywhere). Pull the
# baked version from the builder so the build arg actually controls runtime analytics.
COPY --from=builder /app/packages/shared/src/analytics/baked.ts ./packages/shared/src/analytics/baked.ts
COPY packages/image-engine/src ./packages/image-engine/src
COPY packages/media-engine/src ./packages/media-engine/src
COPY packages/doc-engine/src ./packages/doc-engine/src
COPY packages/ai/src ./packages/ai/src
COPY packages/ai/python ./packages/ai/python
COPY packages/enterprise/src ./packages/enterprise/src
# Copy built frontend from builder stage
COPY --from=builder /app/apps/web/dist ./apps/web/dist
+7
View File
@@ -15,3 +15,10 @@ numpy==1.26.4
Pillow==12.2.0
opencv-python-headless==4.10.0.84
codeformer-pip==0.0.4
# OpenTelemetry: enables the innermost sidecar.<script> span when
# OTEL_EXPORTER_OTLP_ENDPOINT is set (enterprise distributed_tracing). Pure-Python,
# no numpy/scipy deps, so safe for the numpy==1.26.4-locked stack.
opentelemetry-api==1.27.0
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-http==1.27.0
+7
View File
@@ -15,3 +15,10 @@ numpy==1.26.4
Pillow==12.2.0
opencv-python-headless==4.10.0.84
codeformer-pip==0.0.4
# OpenTelemetry: enables the innermost sidecar.<script> span when
# OTEL_EXPORTER_OTLP_ENDPOINT is set (enterprise distributed_tracing). Pure-Python,
# no numpy/scipy deps, so safe for the numpy==1.26.4-locked stack.
opentelemetry-api==1.27.0
opentelemetry-sdk==1.27.0
opentelemetry-exporter-otlp-proto-http==1.27.0
+6 -2
View File
@@ -1,4 +1,4 @@
import type { Readable } from "node:stream";
import { Readable } from "node:stream";
import {
DeleteObjectCommand,
DeleteObjectsCommand,
@@ -184,7 +184,11 @@ export async function putGenericObjectStream(
params: {
Bucket: cfg().bucket,
Key: genericKey(key),
Body: source as unknown as Readable,
// @aws-sdk/lib-storage Upload only accepts string|Uint8Array|Buffer|Readable|
// ReadableStream|Blob. A bare AsyncIterable (the counter() generator from
// object-storage.putObjectStream) is none of those, so S3 uploads failed with
// "Body Data is unsupported format". Wrap it in a real Node Readable.
Body: Readable.from(source),
},
});
await upload.done();
+24 -1
View File
@@ -4,8 +4,20 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
const PRIVATE_KEY_PATH = ".license-signing-key";
// Keep in sync with PLAN_FEATURES in packages/enterprise/src/license.ts.
// isFeatureEnabled() checks the signed license's own `features` array, so a stale
// list here silently leaves gated features 403ing even with a valid enterprise key.
const PLAN_FEATURES = {
team: ["saml_sso", "s3_storage", "multi_tenancy"],
team: [
"saml_sso",
"s3_storage",
"multi_tenancy",
"audit_export",
"siem_forwarding",
"sso_enforcement",
"upgrade_management",
"admin_alerts",
],
enterprise: [
"saml_sso",
"s3_storage",
@@ -15,6 +27,17 @@ const PLAN_FEATURES = {
"audit_export",
"mfa",
"per_tool_permissions",
"siem_forwarding",
"tamper_resistant_audit",
"legal_hold",
"gdpr_lifecycle",
"team_retention_overrides",
"sso_enforcement",
"ip_allowlist",
"config_export_import",
"upgrade_management",
"admin_alerts",
"distributed_tracing",
],
};
@@ -1,62 +0,0 @@
import { expect, test } from "@playwright/test";
// Tests the AuthGuard redirect behavior: fresh users get redirected
// to /analytics-consent, accepted users do not.
test.describe("Consent Redirect - Fresh User", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("/analytics-consent is accessible directly", async ({ page }) => {
await page.goto("/analytics-consent");
// Page should load (may auto-decline if config not yet loaded, but no crash)
await page.waitForTimeout(2000);
// No error page or blank screen
const bodyText = await page.locator("body").textContent();
expect(bodyText).toBeTruthy();
});
test("/privacy is accessible without auth", async ({ page }) => {
await page.goto("/privacy");
await page.waitForTimeout(2000);
expect(page.url()).toContain("/privacy");
await expect(page.getByText(/privacy/i).first()).toBeVisible({ timeout: 5_000 });
});
test("navigating to a protected route without auth redirects to /login", async ({ page }) => {
await page.goto("/resize");
await page.waitForURL(/login/, { timeout: 10_000 });
expect(page.url()).toContain("/login");
});
});
test.describe("Consent Redirect - Accepted User", () => {
// Uses the pre-authenticated storageState where consent was accepted
test("home page loads without consent redirect", async ({ page }) => {
await page.goto("/");
await page.waitForTimeout(2000);
expect(page.url()).not.toContain("analytics-consent");
});
test("tool page loads without consent redirect", async ({ page }) => {
await page.goto("/resize");
await page.waitForTimeout(2000);
expect(page.url()).not.toContain("analytics-consent");
expect(page.url()).toContain("/resize");
});
test("automate page loads without consent redirect", async ({ page }) => {
await page.goto("/automate");
await page.waitForTimeout(2000);
expect(page.url()).not.toContain("analytics-consent");
});
test("navigating between pages never triggers consent redirect", async ({ page }) => {
const pages = ["/", "/resize", "/fullscreen", "/automate", "/"];
for (const path of pages) {
await page.goto(path);
await page.waitForTimeout(500);
expect(page.url()).not.toContain("analytics-consent");
}
});
});
@@ -1,95 +0,0 @@
import { expect, test } from "./helpers";
// These tests use the pre-authenticated storageState from auth.setup.ts
// where the user has already accepted analytics consent.
test.describe("Settings - Product Analytics Tab", () => {
test("Product Analytics nav item is visible in settings", async ({ loggedInPage: page }) => {
await page.locator("aside").getByText("Settings").click();
await expect(page.getByRole("button", { name: /product analytics/i })).toBeVisible({
timeout: 5_000,
});
});
test("clicking Product Analytics tab shows analytics section", async ({ loggedInPage: page }) => {
await page.locator("aside").getByText("Settings").click();
await page.getByRole("button", { name: /product analytics/i }).click();
// Should show the analytics description
await expect(page.getByText(/anonymous usage data/i)).toBeVisible({ timeout: 5_000 });
});
test("toggle shows enabled state after accepting consent", async ({ loggedInPage: page }) => {
await page.locator("aside").getByText("Settings").click();
await page.getByRole("button", { name: /product analytics/i }).click();
// Auth setup accepted consent, so toggle should show enabled
await expect(page.getByText(/analytics enabled/i)).toBeVisible({ timeout: 5_000 });
});
test("toggle off disables analytics", async ({ loggedInPage: page }) => {
await page.locator("aside").getByText("Settings").click();
await page.getByRole("button", { name: /product analytics/i }).click();
// Find and click the toggle button
const toggleButton = page.locator("button.rounded-full");
await toggleButton.click();
await expect(page.getByText(/analytics disabled/i)).toBeVisible({ timeout: 5_000 });
});
test("toggle on re-enables analytics", async ({ loggedInPage: page }) => {
await page.locator("aside").getByText("Settings").click();
await page.getByRole("button", { name: /product analytics/i }).click();
const toggleButton = page.locator("button.rounded-full");
// Ensure we're in disabled state first
const text = await page.getByText(/analytics (enabled|disabled)/i).textContent();
if (text?.toLowerCase().includes("enabled")) {
await toggleButton.click();
await expect(page.getByText(/analytics disabled/i)).toBeVisible({ timeout: 5_000 });
}
// Now toggle on
await toggleButton.click();
await expect(page.getByText(/analytics enabled/i)).toBeVisible({ timeout: 5_000 });
});
test("toggle state persists after closing and reopening settings", async ({
loggedInPage: page,
}) => {
// Open settings and disable analytics
await page.locator("aside").getByText("Settings").click();
await page.getByRole("button", { name: /product analytics/i }).click();
const toggleButton = page.locator("button.rounded-full");
// Ensure enabled, then disable
const text = await page.getByText(/analytics (enabled|disabled)/i).textContent();
if (text?.toLowerCase().includes("enabled")) {
await toggleButton.click();
await expect(page.getByText(/analytics disabled/i)).toBeVisible({ timeout: 5_000 });
}
// Close dialog
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
// Reopen and check the state persisted
await page.locator("aside").getByText("Settings").click();
await page.getByRole("button", { name: /product analytics/i }).click();
await expect(page.getByText(/analytics disabled/i)).toBeVisible({ timeout: 5_000 });
// Re-enable for other tests
await toggleButton.click();
await expect(page.getByText(/analytics enabled/i)).toBeVisible({ timeout: 5_000 });
});
test("privacy policy link is present", async ({ loggedInPage: page }) => {
await page.locator("aside").getByText("Settings").click();
await page.getByRole("button", { name: /product analytics/i }).click();
await expect(page.getByText(/privacy/i)).toBeVisible({ timeout: 5_000 });
});
});