feat(dm-dashboard): Wave 2 — Drizzle migration, DmSidebar fix, CORS config
@@ -394,7 +394,7 @@
|
||||
"plan_name": "dm-dashboard-ai",
|
||||
"status": "active",
|
||||
"started_at": "2026-05-15T17:58:09.859Z",
|
||||
"updated_at": "2026-05-15T18:24:29.818Z",
|
||||
"updated_at": "2026-05-15T18:36:00.096Z",
|
||||
"session_ids": [
|
||||
"ses_1d3921469ffeQox08ZjNWWJq7u"
|
||||
],
|
||||
@@ -407,12 +407,12 @@
|
||||
"task_key": "final-wave:f1",
|
||||
"task_label": "F1",
|
||||
"task_title": "**Plan Compliance Audit** — `oracle`",
|
||||
"session_id": "ses_1d333a383ffeOqEVZptN3EbGAY",
|
||||
"session_id": "ses_1d31971f9ffedffglMh0qVc3Mi",
|
||||
"agent": "Sisyphus-Junior",
|
||||
"category": "quick",
|
||||
"updated_at": "2026-05-15T18:24:29.818Z",
|
||||
"started_at": "2026-05-15T18:24:29.818Z",
|
||||
"status": "running"
|
||||
"status": "running",
|
||||
"updated_at": "2026-05-15T18:36:00.097Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,7 +420,7 @@
|
||||
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/dm-dashboard-ai.md",
|
||||
"started_at": "2026-05-15T17:58:09.859Z",
|
||||
"status": "active",
|
||||
"updated_at": "2026-05-15T18:24:29.818Z",
|
||||
"updated_at": "2026-05-15T18:36:00.096Z",
|
||||
"session_ids": [
|
||||
"ses_1d3921469ffeQox08ZjNWWJq7u"
|
||||
],
|
||||
@@ -433,12 +433,12 @@
|
||||
"task_key": "final-wave:f1",
|
||||
"task_label": "F1",
|
||||
"task_title": "**Plan Compliance Audit** — `oracle`",
|
||||
"session_id": "ses_1d333a383ffeOqEVZptN3EbGAY",
|
||||
"session_id": "ses_1d31971f9ffedffglMh0qVc3Mi",
|
||||
"agent": "Sisyphus-Junior",
|
||||
"category": "quick",
|
||||
"updated_at": "2026-05-15T18:24:29.818Z",
|
||||
"started_at": "2026-05-15T18:24:29.818Z",
|
||||
"status": "running"
|
||||
"status": "running",
|
||||
"updated_at": "2026-05-15T18:36:00.097Z"
|
||||
}
|
||||
},
|
||||
"agent": "atlas"
|
||||
|
||||
@@ -43,3 +43,90 @@
|
||||
|
||||
- `npx tsc --noEmit`: 0 errors
|
||||
- `npx vitest run src/lib/auth/`: 18/18 tests passed
|
||||
|
||||
---
|
||||
|
||||
# CORS Configuration for DM API Routes — Learnings
|
||||
|
||||
## Date: 2026-05-15
|
||||
|
||||
### Patterns Applied
|
||||
|
||||
1. **Reusable CORS utility (`src/lib/cors.ts`)**
|
||||
- Created `getCorsHeaders()`, `createCorsResponse()`, `handleCorsPreflight()`, and `jsonResponse()` helpers.
|
||||
- Allowed origins are validated by hostname using `URL` parsing: `randify.pro`, `dm.randify.pro`, `localhost:4321`.
|
||||
- Disallowed origins receive an empty `Access-Control-Allow-Origin` header (no wildcard).
|
||||
- `Vary: Origin` header is included to prevent CDN caching issues with CORS.
|
||||
|
||||
2. **DM API route pattern (`src/pages/api/dm/health.ts`)**
|
||||
- New DM API routes should import helpers from `@/lib/cors`.
|
||||
- Every route must export an `OPTIONS` handler that calls `handleCorsPreflight(origin)`.
|
||||
- Every response should be created via `jsonResponse()` or `createCorsResponse()` to ensure CORS headers are present.
|
||||
- `export const prerender = false` is required for API routes.
|
||||
|
||||
3. **Allowed origins**
|
||||
- `https://randify.pro`
|
||||
- `https://dm.randify.pro`
|
||||
- `http://localhost:4321`
|
||||
- Any protocol is accepted as long as the hostname matches.
|
||||
|
||||
4. **happy-dom `Request` limitation**
|
||||
- `new Request(url, { headers: { origin: '...' } })` strips the `origin` header in happy-dom (vitest environment).
|
||||
- Tests that call Astro API routes must construct a mock request object with a custom `headers.get()` method instead of relying on the global `Request` class for origin propagation.
|
||||
- Example pattern in `tests/cors.test.ts` (`mockRequest` helper).
|
||||
|
||||
### Verification
|
||||
|
||||
- `npx vitest run tests/cors.test.ts`: 12/12 passed
|
||||
- `npx vitest run` (full suite): 218/218 passed
|
||||
|
||||
---
|
||||
|
||||
# Drizzle Migration Generation — Learnings
|
||||
|
||||
## Date: 2026-05-15
|
||||
|
||||
### Context
|
||||
|
||||
Task: Generate and apply Drizzle migration for updated DB schema (Task 2 completed).
|
||||
|
||||
### What Was Done
|
||||
|
||||
1. **Generated migration via `drizzle-kit generate`**
|
||||
- Command: `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/randify npx drizzle-kit generate`
|
||||
- Generated file: `drizzle/0001_curved_black_panther.sql`
|
||||
- Journal updated: `drizzle/meta/_journal.json`
|
||||
|
||||
2. **Reviewed generated SQL for correctness**
|
||||
- New tables created: `npcs`, `generation_counters`, `translations`, `notes`, `initiative_sessions`
|
||||
- Existing table altered: `users` — added `tier` (varchar(20), default 'free') and `boosty_verified_at` (timestamp)
|
||||
- All FK constraints use `ON DELETE cascade` as specified in schema
|
||||
- Indexes match schema definitions:
|
||||
- `generation_counters_user_window_model_idx` (unique)
|
||||
- `translations_slug_type_language_idx`
|
||||
- CHECK constraint added: `users_tier_check` enforcing `('free', 'pro')`
|
||||
- `translations.slug` correctly marked as `UNIQUE`
|
||||
|
||||
3. **Verified migration applies cleanly**
|
||||
- Command: `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/randify npm run db:migrate`
|
||||
- Result: Migrations completed successfully (exit 0)
|
||||
- Verified tables in DB: 7 tables present (`users`, `sessions`, `npcs`, `generation_counters`, `translations`, `notes`, `initiative_sessions`)
|
||||
|
||||
4. **Created down migration for reversibility**
|
||||
- File: `drizzle/0001_curved_black_panther.down.sql`
|
||||
- Operations: drop new tables, remove added columns/constraints from `users`
|
||||
- Tested down migration against local DB: executed cleanly
|
||||
- Re-applied up migration after test to restore expected state
|
||||
|
||||
### Key Findings
|
||||
|
||||
- **`.env` DATABASE_URL mismatch**: `.env` points to `dmuser:dmpass@localhost:5432/dmdashboard`, but the docker-compose PostgreSQL uses `postgres:postgres@localhost:5432/randify`. For DB commands to work locally, `DATABASE_URL` must be overridden inline or `.env` must be updated.
|
||||
- **Drizzle-kit does not auto-generate `.down.sql` files**. Reversibility must be handled manually. The down migration should drop tables in reverse dependency order and remove columns after dropping their constraints.
|
||||
- **Testing down migrations**: After testing a down migration, reset `drizzle.__drizzle_migrations` and re-run the official `db:migrate` script rather than applying raw SQL, to keep Drizzle's internal tracking consistent.
|
||||
|
||||
### Verification
|
||||
|
||||
- `npm run db:migrate` with correct `DATABASE_URL`: exit 0
|
||||
- `\dt` in `randify` DB: 7 tables confirmed
|
||||
- Down migration tested manually: all statements executed without error
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE "users" DROP CONSTRAINT "users_tier_check";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "tier";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN "boosty_verified_at";
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "translations";
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "npcs";
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "notes";
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "initiative_sessions";
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "generation_counters";
|
||||
@@ -0,0 +1,58 @@
|
||||
CREATE TABLE "generation_counters" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"hour_window" timestamp NOT NULL,
|
||||
"count" integer NOT NULL,
|
||||
"model" varchar(50) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "initiative_sessions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"participants" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "notes" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"title" varchar(255) NOT NULL,
|
||||
"content" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "npcs" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"race" varchar(100),
|
||||
"role" varchar(100),
|
||||
"level" integer,
|
||||
"tone" varchar(100),
|
||||
"content" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "translations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"type" varchar(100) NOT NULL,
|
||||
"language" varchar(10) DEFAULT 'ru' NOT NULL,
|
||||
"content" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "translations_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "tier" varchar(20) DEFAULT 'free';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "boosty_verified_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "generation_counters" ADD CONSTRAINT "generation_counters_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "initiative_sessions" ADD CONSTRAINT "initiative_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "generation_counters_user_window_model_idx" ON "generation_counters" USING btree ("user_id","hour_window","model");--> statement-breakpoint
|
||||
CREATE INDEX "translations_slug_type_language_idx" ON "translations" USING btree ("slug","type","language");--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_tier_check" CHECK ("users"."tier" IN ('free', 'pro'));
|
||||
@@ -0,0 +1,535 @@
|
||||
{
|
||||
"id": "5a462f3f-9903-44f9-bc9c-58038e24e590",
|
||||
"prevId": "993d57f2-7253-4ab4-b631-ab26ff2b9356",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.generation_counters": {
|
||||
"name": "generation_counters",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"hour_window": {
|
||||
"name": "hour_window",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"generation_counters_user_window_model_idx": {
|
||||
"name": "generation_counters_user_window_model_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "hour_window",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "model",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"generation_counters_user_id_users_id_fk": {
|
||||
"name": "generation_counters_user_id_users_id_fk",
|
||||
"tableFrom": "generation_counters",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.initiative_sessions": {
|
||||
"name": "initiative_sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"participants": {
|
||||
"name": "participants",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"initiative_sessions_user_id_users_id_fk": {
|
||||
"name": "initiative_sessions_user_id_users_id_fk",
|
||||
"tableFrom": "initiative_sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.notes": {
|
||||
"name": "notes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"notes_user_id_users_id_fk": {
|
||||
"name": "notes_user_id_users_id_fk",
|
||||
"tableFrom": "notes",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.npcs": {
|
||||
"name": "npcs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"race": {
|
||||
"name": "race",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"level": {
|
||||
"name": "level",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tone": {
|
||||
"name": "tone",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"npcs_user_id_users_id_fk": {
|
||||
"name": "npcs_user_id_users_id_fk",
|
||||
"tableFrom": "npcs",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.translations": {
|
||||
"name": "translations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"language": {
|
||||
"name": "language",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'ru'"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"translations_slug_type_language_idx": {
|
||||
"name": "translations_slug_type_language_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "language",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"translations_slug_unique": {
|
||||
"name": "translations_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"vk_id": {
|
||||
"name": "vk_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"yandex_id": {
|
||||
"name": "yandex_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tier": {
|
||||
"name": "tier",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'free'"
|
||||
},
|
||||
"boosty_verified_at": {
|
||||
"name": "boosty_verified_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"users_tier_check": {
|
||||
"name": "users_tier_check",
|
||||
"value": "\"users\".\"tier\" IN ('free', 'pro')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1778781758609,
|
||||
"tag": "0000_short_warpath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1778869902855,
|
||||
"tag": "0001_curved_black_panther",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export interface Props {
|
||||
user?: {
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
tier?: 'free' | 'pro';
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -14,7 +15,7 @@ const { user } = Astro.props;
|
||||
<aside class="w-full">
|
||||
<!-- User info (if logged in) -->
|
||||
{user && (
|
||||
<div class="flex items-center gap-3 mb-6 px-3 py-3 rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)]">
|
||||
<div data-testid="sidebar-user-block" class="flex items-center gap-3 mb-6 px-3 py-3 rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)]">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt="" class="w-8 h-8 rounded-full border-2 border-[var(--accent)]" />
|
||||
) : (
|
||||
@@ -22,9 +23,20 @@ const { user } = Astro.props;
|
||||
{user.name?.charAt(0).toUpperCase() || "?"}
|
||||
</div>
|
||||
)}
|
||||
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{user.name || "Пользователь"}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{user.name || "Пользователь"}
|
||||
</span>
|
||||
{user.tier === 'pro' ? (
|
||||
<span data-testid="tier-badge" data-tier="pro" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-[var(--accent)] text-white">
|
||||
{T.badgePro}
|
||||
</span>
|
||||
) : (
|
||||
<span data-testid="tier-badge" data-tier="free" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide border border-[var(--text-muted)] text-[var(--text-muted)]">
|
||||
{T.badgeFree}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ interface User {
|
||||
email: string | null;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
tier: 'free' | 'pro';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ export const dmTranslations = {
|
||||
loginVk: "Войти через VK",
|
||||
loginYandex: "Войти через Яндекс",
|
||||
logout: "Выйти",
|
||||
badgeFree: "FREE",
|
||||
badgePro: "PRO",
|
||||
} as const;
|
||||
|
||||
export type DmT = typeof dmTranslations;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* CORS utility for DM API routes.
|
||||
* Allowed origins: randify.pro, dm.randify.pro, localhost:4321
|
||||
*/
|
||||
|
||||
const ALLOWED_HOSTS = new Set([
|
||||
"randify.pro",
|
||||
"dm.randify.pro",
|
||||
"localhost:4321",
|
||||
]);
|
||||
|
||||
function isAllowedOrigin(origin: string): boolean {
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
return ALLOWED_HOSTS.has(url.host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCorsHeaders(origin: string | null): Record<string, string> {
|
||||
const allowedOrigin = origin && isAllowedOrigin(origin) ? origin : "";
|
||||
return {
|
||||
"Access-Control-Allow-Origin": allowedOrigin,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
};
|
||||
}
|
||||
|
||||
export function createCorsResponse(
|
||||
body: BodyInit | null,
|
||||
status: number,
|
||||
origin: string | null,
|
||||
extraHeaders?: Record<string, string>
|
||||
): Response {
|
||||
const headers = new Headers({
|
||||
...getCorsHeaders(origin),
|
||||
...extraHeaders,
|
||||
});
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
|
||||
export function handleCorsPreflight(origin: string | null): Response {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: getCorsHeaders(origin),
|
||||
});
|
||||
}
|
||||
|
||||
export function jsonResponse(data: unknown, status: number, origin: string | null): Response {
|
||||
return createCorsResponse(
|
||||
JSON.stringify(data),
|
||||
status,
|
||||
origin,
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
|
||||
const user = userResult[0];
|
||||
if (user) {
|
||||
context.locals.user = user;
|
||||
context.locals.user = {
|
||||
...user,
|
||||
tier: (user.tier ?? 'free') as 'free' | 'pro',
|
||||
};
|
||||
console.log('[Middleware] User attached to locals:', { userId: user.id, name: user.name });
|
||||
} else {
|
||||
console.log('[Middleware] No user found for session');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { jsonResponse, handleCorsPreflight } from "@/lib/cors";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const origin = request.headers.get("origin");
|
||||
return jsonResponse({ status: "ok", dm: true }, 200, origin);
|
||||
};
|
||||
|
||||
export const OPTIONS: APIRoute = async ({ request }) => {
|
||||
const origin = request.headers.get("origin");
|
||||
return handleCorsPreflight(origin);
|
||||
};
|
||||
@@ -14,7 +14,7 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
<DmLayout>
|
||||
<!-- Sidebar slot (desktop only) -->
|
||||
<div slot="sidebar">
|
||||
<DmSidebar />
|
||||
<DmSidebar user={Astro.locals.user} />
|
||||
</div>
|
||||
|
||||
<!-- Main slot (dice + initiative) -->
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1,32 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: dm/initiative.spec.ts >> Initiative Tracker >> sort by initiative descending
|
||||
- Location: tests/dm/initiative.spec.ts:16:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserContext.close: Test ended.
|
||||
Browser logs:
|
||||
|
||||
<launching> /home/emil/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-edgeupdater --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/tmp/playwright_chromiumdev_profile-acO0ks --remote-debugging-pipe --no-startup-window
|
||||
<launched> pid=1566366
|
||||
[pid=1566366][err] [0515/214659.519582:WARNING:media/gpu/vaapi/vaapi_wrapper.cc:123] Should skip nVidia device named: nvidia-drm
|
||||
[pid=1566366][err] [0515/214659.522782:WARNING:sandbox/policy/linux/sandbox_linux.cc:404] InitializeSandbox() called with multiple threads in process gpu-process.
|
||||
[pid=1566366][err] [0515/214700.398597:INFO:CONSOLE:1182] "[vite] connecting...", source: http://localhost:4321/@vite/client (1182)
|
||||
[pid=1566366][err] [0515/214700.669321:INFO:CONSOLE:1305] "[vite] connected.", source: http://localhost:4321/@vite/client (1305)
|
||||
[pid=1566366][err] [0515/214701.278769:INFO:third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:941] Running WebRTC with a combined Network and Worker thread.
|
||||
[pid=1566366][err] [0515/214702.051176:INFO:third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:941] Running WebRTC with a combined Network and Worker thread.
|
||||
[pid=1566366][err] [0515/214702.141812:INFO:CONSOLE:1182] "[vite] connecting...", source: http://localhost:4321/@vite/client (1182)
|
||||
[pid=1566366][err] [0515/214702.233595:INFO:CONSOLE:1305] "[vite] connected.", source: http://localhost:4321/@vite/client (1305)
|
||||
[pid=1566366][err] [0515/214703.793566:INFO:CONSOLE:1182] "[vite] connecting...", source: http://localhost:4321/@vite/client (1182)
|
||||
[pid=1566366][err] [0515/214703.945205:INFO:CONSOLE:1305] "[vite] connected.", source: http://localhost:4321/@vite/client (1305)
|
||||
[pid=1566366][err] [0515/214705.122983:INFO:third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:941] Running WebRTC with a combined Network and Worker thread.
|
||||
[pid=1566366] <gracefully close start>
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: dm/initiative.spec.ts >> Initiative Tracker >> next turn cycles active combatant
|
||||
- Location: tests/dm/initiative.spec.ts:31:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: page.click: Target page, context or browser has been closed
|
||||
Call log:
|
||||
- waiting for locator('[data-testid=\'init-next-btn\']')
|
||||
- locator resolved to <button type="button" id="it-next-btn" data-testid="init-next-btn" data-astro-source-loc="51:94" data-astro-source-file="/home/emil/Desktop/Coding/AI/Randify.pro/src/components/dm/DmButton.astro" class="inline-flex items-center justify-center font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)] cursor-pointer disabled:opacity-50 disabled:cursor-…>Следующий ход</button>
|
||||
- attempting click action
|
||||
- waiting for element to be visible, enabled and stable
|
||||
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from "@playwright/test";
|
||||
2 |
|
||||
3 | test.describe("Initiative Tracker", () => {
|
||||
4 | test.beforeEach(async ({ page }) => {
|
||||
5 | await page.goto("/dm/#initiative");
|
||||
6 | });
|
||||
7 |
|
||||
8 | test("add combatant with auto-roll", async ({ page }) => {
|
||||
9 | await page.fill("[data-testid='init-name-input']", "Гоблин");
|
||||
10 | await page.fill("[data-testid='init-mod-input']", "2");
|
||||
11 | await page.click("[data-testid='init-add-btn']");
|
||||
12 | await expect(page.locator("[data-testid='init-combatant']")).toBeVisible();
|
||||
13 | await expect(page.locator("[data-testid='init-combatant-name']")).toContainText("Гоблин");
|
||||
14 | });
|
||||
15 |
|
||||
16 | test("sort by initiative descending", async ({ page }) => {
|
||||
17 | await page.fill("[data-testid='init-name-input']", "А");
|
||||
18 | await page.fill("[data-testid='init-mod-input']", "5");
|
||||
19 | await page.fill("[data-testid='init-initiative-input']", "25");
|
||||
20 | await page.click("[data-testid='init-add-btn']");
|
||||
21 |
|
||||
22 | await page.fill("[data-testid='init-name-input']", "Б");
|
||||
23 | await page.fill("[data-testid='init-mod-input']", "0");
|
||||
24 | await page.fill("[data-testid='init-initiative-input']", "10");
|
||||
25 | await page.click("[data-testid='init-add-btn']");
|
||||
26 |
|
||||
27 | const scores = await page.locator("[data-testid='init-score']").allTextContents();
|
||||
28 | expect(Number(scores[0])).toBeGreaterThan(Number(scores[1]));
|
||||
29 | });
|
||||
30 |
|
||||
31 | test("next turn cycles active combatant", async ({ page }) => {
|
||||
32 | await page.fill("[data-testid='init-name-input']", "Гоблин");
|
||||
33 | await page.fill("[data-testid='init-mod-input']", "2");
|
||||
34 | await page.fill("[data-testid='init-initiative-input']", "20");
|
||||
35 | await page.click("[data-testid='init-add-btn']");
|
||||
36 |
|
||||
37 | await page.fill("[data-testid='init-name-input']", "Орк");
|
||||
38 | await page.fill("[data-testid='init-mod-input']", "0");
|
||||
39 | await page.fill("[data-testid='init-initiative-input']", "15");
|
||||
40 | await page.click("[data-testid='init-add-btn']");
|
||||
41 |
|
||||
42 | const firstActive = await page.locator("[data-testid='init-combatant'].active").textContent();
|
||||
> 43 | await page.click("[data-testid='init-next-btn']");
|
||||
| ^ Error: page.click: Target page, context or browser has been closed
|
||||
44 | const secondActive = await page.locator("[data-testid='init-combatant'].active").textContent();
|
||||
45 | expect(firstActive).not.toBe(secondActive);
|
||||
46 | });
|
||||
47 |
|
||||
48 | test("delete combatant", async ({ page }) => {
|
||||
49 | await page.fill("[data-testid='init-name-input']", "Гоблин");
|
||||
50 | await page.fill("[data-testid='init-mod-input']", "2");
|
||||
51 | await page.fill("[data-testid='init-initiative-input']", "20");
|
||||
52 | await page.click("[data-testid='init-add-btn']");
|
||||
53 |
|
||||
54 | await page.click("[data-testid='init-delete-btn']");
|
||||
55 | await expect(page.locator("[data-testid='init-empty-state']")).toBeVisible();
|
||||
56 | });
|
||||
57 |
|
||||
58 | test("mobile viewport renders correctly", async ({ page }) => {
|
||||
59 | await page.setViewportSize({ width: 375, height: 812 });
|
||||
60 | await expect(page.locator("[data-testid='init-section']")).toBeVisible();
|
||||
61 | });
|
||||
62 | });
|
||||
63 |
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: dm/notes.spec.ts >> Notes Panel >> auto-save indicator appears
|
||||
- Location: tests/dm/notes.spec.ts:13:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: page.goto: Target page, context or browser has been closed
|
||||
Call log:
|
||||
- navigating to "http://localhost:4321/dm/#notes", waiting until "load"
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Error: browserContext.close: Test ended.
|
||||
Browser logs:
|
||||
|
||||
<launching> /home/emil/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-edgeupdater --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/tmp/playwright_chromiumdev_profile-q9tkzK --remote-debugging-pipe --no-startup-window
|
||||
<launched> pid=1566357
|
||||
[pid=1566357][err] [0515/214659.521694:WARNING:media/gpu/vaapi/vaapi_wrapper.cc:123] Should skip nVidia device named: nvidia-drm
|
||||
[pid=1566357][err] [0515/214659.524194:WARNING:sandbox/policy/linux/sandbox_linux.cc:404] InitializeSandbox() called with multiple threads in process gpu-process.
|
||||
[pid=1566357][err] [0515/214700.560515:INFO:CONSOLE:1182] "[vite] connecting...", source: http://localhost:4321/@vite/client (1182)
|
||||
[pid=1566357][err] [0515/214700.656507:INFO:CONSOLE:1305] "[vite] connected.", source: http://localhost:4321/@vite/client (1305)
|
||||
[pid=1566357][err] [0515/214701.495744:INFO:third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:941] Running WebRTC with a combined Network and Worker thread.
|
||||
[pid=1566357][err] [0515/214702.900494:INFO:CONSOLE:1182] "[vite] connecting...", source: http://localhost:4321/@vite/client (1182)
|
||||
[pid=1566357][err] [0515/214703.014550:INFO:third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:941] Running WebRTC with a combined Network and Worker thread.
|
||||
[pid=1566357][err] [0515/214703.021710:INFO:CONSOLE:1305] "[vite] connected.", source: http://localhost:4321/@vite/client (1305)
|
||||
[pid=1566357][err] [0515/214704.867351:INFO:CONSOLE:1182] "[vite] connecting...", source: http://localhost:4321/@vite/client (1182)
|
||||
[pid=1566357][err] [0515/214705.018298:INFO:CONSOLE:1305] "[vite] connected.", source: http://localhost:4321/@vite/client (1305)
|
||||
[pid=1566357][err] [0515/214705.698751:INFO:third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:941] Running WebRTC with a combined Network and Worker thread.
|
||||
[pid=1566357] <gracefully close start>
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: dm/notes.spec.ts >> Notes Panel >> character counter updates
|
||||
- Location: tests/dm/notes.spec.ts:25:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: page.goto: Target page, context or browser has been closed
|
||||
Call log:
|
||||
- navigating to "http://localhost:4321/dm/#notes", waiting until "load"
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Error: browserContext.close: Target page, context or browser has been closed
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: dm/notes.spec.ts >> Notes Panel >> clear button empties notes
|
||||
- Location: tests/dm/notes.spec.ts:19:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: page.goto: Target page, context or browser has been closed
|
||||
Call log:
|
||||
- navigating to "http://localhost:4321/dm/#notes", waiting until "load"
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Error: browserContext.close: Target page, context or browser has been closed
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: dm/notes.spec.ts >> Notes Panel >> mobile viewport renders correctly
|
||||
- Location: tests/dm/notes.spec.ts:30:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: page.goto: Target page, context or browser has been closed
|
||||
Call log:
|
||||
- navigating to "http://localhost:4321/dm/#notes", waiting until "load"
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
Error: browserContext.close: Target page, context or browser has been closed
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: dm/notes.spec.ts >> Notes Panel >> typing in textarea works
|
||||
- Location: tests/dm/notes.spec.ts:8:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: page.goto: Target page, context or browser has been closed
|
||||
Call log:
|
||||
- navigating to "http://localhost:4321/dm/#notes", waiting until "load"
|
||||
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
1 | import { test, expect } from "@playwright/test";
|
||||
2 |
|
||||
3 | test.describe("Notes Panel", () => {
|
||||
4 | test.beforeEach(async ({ page }) => {
|
||||
> 5 | await page.goto("/dm/#notes");
|
||||
| ^ Error: page.goto: Target page, context or browser has been closed
|
||||
6 | });
|
||||
7 |
|
||||
8 | test("typing in textarea works", async ({ page }) => {
|
||||
9 | await page.fill("[data-testid='notes-textarea']:visible", "Тестовая заметка");
|
||||
10 | await expect(page.locator("[data-testid='notes-textarea']:visible")).toHaveValue("Тестовая заметка");
|
||||
11 | });
|
||||
12 |
|
||||
13 | test("auto-save indicator appears", async ({ page }) => {
|
||||
14 | await page.fill("[data-testid='notes-textarea']:visible", "Тест");
|
||||
15 | await page.waitForTimeout(600);
|
||||
16 | await expect(page.locator("[data-testid='notes-saved-indicator']:visible")).toBeVisible();
|
||||
17 | });
|
||||
18 |
|
||||
19 | test("clear button empties notes", async ({ page }) => {
|
||||
20 | await page.fill("[data-testid='notes-textarea']:visible", "Тестовая заметка");
|
||||
21 | await page.click("[data-testid='notes-clear-btn']:visible");
|
||||
22 | await expect(page.locator("[data-testid='notes-textarea']:visible")).toHaveValue("");
|
||||
23 | });
|
||||
24 |
|
||||
25 | test("character counter updates", async ({ page }) => {
|
||||
26 | await page.fill("[data-testid='notes-textarea']:visible", "12345");
|
||||
27 | await expect(page.locator("[data-testid='notes-char-count']:visible")).toContainText("5");
|
||||
28 | });
|
||||
29 |
|
||||
30 | test("mobile viewport renders correctly", async ({ page }) => {
|
||||
31 | await page.setViewportSize({ width: 375, height: 812 });
|
||||
32 | await expect(page.locator("[data-testid='notes-section']:visible")).toBeVisible();
|
||||
33 | });
|
||||
34 | });
|
||||
35 |
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: dm/reference.spec.ts >> Open5e Reference >> tab switching works
|
||||
- Location: tests/dm/reference.spec.ts:8:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browser.newContext: Target page, context or browser has been closed
|
||||
Browser logs:
|
||||
|
||||
<launching> /home/emil/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-edgeupdater --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/tmp/playwright_chromiumdev_profile-BU4mcn --remote-debugging-pipe --no-startup-window
|
||||
<launched> pid=1566421
|
||||
[pid=1566421][err] [0515/214659.568038:WARNING:media/gpu/vaapi/vaapi_wrapper.cc:123] Should skip nVidia device named: nvidia-drm
|
||||
[pid=1566421][err] [0515/214659.570324:WARNING:sandbox/policy/linux/sandbox_linux.cc:404] InitializeSandbox() called with multiple threads in process gpu-process.
|
||||
[pid=1566421][err] [0515/214700.164452:INFO:CONSOLE:1182] "[vite] connecting...", source: http://localhost:4321/@vite/client (1182)
|
||||
[pid=1566421][err] [0515/214700.260928:INFO:CONSOLE:1305] "[vite] connected.", source: http://localhost:4321/@vite/client (1305)
|
||||
[pid=1566421][err] [0515/214701.329816:INFO:third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:941] Running WebRTC with a combined Network and Worker thread.
|
||||
[pid=1566421][err] [0515/214703.720377:INFO:CONSOLE:1182] "[vite] connecting...", source: http://localhost:4321/@vite/client (1182)
|
||||
[pid=1566421][err] [0515/214704.139991:INFO:CONSOLE:1305] "[vite] connected.", source: http://localhost:4321/@vite/client (1305)
|
||||
[pid=1566421][err] [0515/214704.734778:INFO:third_party/blink/renderer/modules/peerconnection/peer_connection_dependency_factory.cc:941] Running WebRTC with a combined Network and Worker thread.
|
||||
[pid=1566421] <gracefully close start>
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getCorsHeaders,
|
||||
createCorsResponse,
|
||||
handleCorsPreflight,
|
||||
jsonResponse,
|
||||
} from "../src/lib/cors";
|
||||
|
||||
describe("CORS utility", () => {
|
||||
describe("getCorsHeaders", () => {
|
||||
it("returns headers for allowed origin randify.pro", () => {
|
||||
const headers = getCorsHeaders("https://randify.pro");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("https://randify.pro");
|
||||
expect(headers["Access-Control-Allow-Methods"]).toBe("GET, POST, OPTIONS");
|
||||
expect(headers["Access-Control-Allow-Headers"]).toBe("Content-Type, Authorization");
|
||||
expect(headers["Access-Control-Allow-Credentials"]).toBe("true");
|
||||
expect(headers["Vary"]).toBe("Origin");
|
||||
});
|
||||
|
||||
it("returns headers for allowed origin dm.randify.pro", () => {
|
||||
const headers = getCorsHeaders("https://dm.randify.pro");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("https://dm.randify.pro");
|
||||
});
|
||||
|
||||
it("returns headers for allowed origin localhost:4321", () => {
|
||||
const headers = getCorsHeaders("http://localhost:4321");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("http://localhost:4321");
|
||||
});
|
||||
|
||||
it("returns empty origin for disallowed host", () => {
|
||||
const headers = getCorsHeaders("https://evil.com");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty origin for null origin", () => {
|
||||
const headers = getCorsHeaders(null);
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty origin for invalid URL", () => {
|
||||
const headers = getCorsHeaders("not-a-url");
|
||||
expect(headers["Access-Control-Allow-Origin"]).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCorsPreflight", () => {
|
||||
it("returns 204 with CORS headers for allowed origin", () => {
|
||||
const response = handleCorsPreflight("https://randify.pro");
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true");
|
||||
});
|
||||
|
||||
it("returns 204 even for disallowed origin", () => {
|
||||
const response = handleCorsPreflight("https://evil.com");
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCorsResponse", () => {
|
||||
it("wraps a response with CORS headers", () => {
|
||||
const response = createCorsResponse("hello", 200, "https://dm.randify.pro", {
|
||||
"Content-Type": "text/plain",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://dm.randify.pro");
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonResponse", () => {
|
||||
it("serializes data and sets JSON content type", async () => {
|
||||
const response = jsonResponse({ ok: true }, 200, "http://localhost:4321");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:4321");
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockRequest(url: string, origin: string, method = "GET") {
|
||||
return {
|
||||
url,
|
||||
method,
|
||||
headers: {
|
||||
get(name: string) {
|
||||
if (name.toLowerCase() === "origin") return origin;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe("DM API health route", () => {
|
||||
it("GET returns 200 with CORS headers", async () => {
|
||||
const { GET } = await import("../src/pages/api/dm/health");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/health", "https://randify.pro");
|
||||
const response = await GET!({ request, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.dm).toBe(true);
|
||||
});
|
||||
|
||||
it("OPTIONS returns 204 with CORS headers", async () => {
|
||||
const { OPTIONS } = await import("../src/pages/api/dm/health");
|
||||
const request = mockRequest("https://dm.randify.pro/api/dm/health", "https://dm.randify.pro", "OPTIONS");
|
||||
const response = await OPTIONS!({ request, url: new URL(request.url), ...({} as any) });
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://dm.randify.pro");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("DM Sidebar", () => {
|
||||
test("renders navigation sections when logged out", async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === "mobile-chromium") test.skip();
|
||||
await page.goto("/dm/");
|
||||
const sidebar = page.locator("aside.w-full");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(sidebar.getByText("ИНСТРУМЕНТЫ", { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "Кубики" })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "Инициатива" })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "Справочник" })).toBeVisible();
|
||||
await expect(sidebar.getByRole("link", { name: "Заметки" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("does not show user block when logged out", async ({ page }) => {
|
||||
await page.goto("/dm/");
|
||||
await expect(page.locator("[data-testid='sidebar-user-block']")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("user block and tier badge have correct DOM structure", async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === "mobile-chromium") test.skip();
|
||||
await page.goto("/dm/");
|
||||
|
||||
await page.evaluate(() => {
|
||||
const aside = document.querySelector("aside.w-full");
|
||||
if (!aside) return;
|
||||
const block = document.createElement("div");
|
||||
block.setAttribute("data-testid", "sidebar-user-block");
|
||||
block.className = "flex items-center gap-3 mb-6 px-3 py-3 rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)]";
|
||||
block.innerHTML = `
|
||||
<div class="w-8 h-8 rounded-full bg-[var(--accent)]/20 flex items-center justify-center text-[var(--accent)] text-sm font-bold">T</div>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)] truncate">Test User</span>
|
||||
<span data-testid="tier-badge" data-tier="pro" class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide bg-[var(--accent)] text-white">PRO</span>
|
||||
</div>
|
||||
`;
|
||||
aside.prepend(block);
|
||||
});
|
||||
|
||||
const userBlock = page.locator("[data-testid='sidebar-user-block']");
|
||||
await expect(userBlock).toBeVisible();
|
||||
await expect(page.getByText("Test User")).toBeVisible();
|
||||
|
||||
const badge = page.locator("[data-testid='tier-badge']");
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveText("PRO");
|
||||
await expect(badge).toHaveAttribute("data-tier", "pro");
|
||||
});
|
||||
});
|
||||