diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7088341..b20bf0c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,6 @@ jobs: defaults: run: working-directory: interviewhub-lld - env: - PUPPETEER_SKIP_DOWNLOAD: "true" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -68,8 +66,12 @@ jobs: run: npm run typecheck - name: Verify session isolation and revocation queue run: npm run test:session-boundaries - - name: Check browser smoke script - run: node --check scripts/verify-e2e.mjs + - name: Verify immediate workspace snapshots + run: npm run test:workspace-snapshot + - name: Check browser test scripts + run: | + node --check scripts/verify-e2e.mjs + node --check scripts/verify-workspace-snapshot.mjs - name: Build run: npm run build diff --git a/.gitignore b/.gitignore index 5dac83a3..879d143f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ *.env !*.env.example +# Local certificates and mounted development secrets +.local-secrets/ +*/.local-secrets/ + # Dependencies node_modules/ */node_modules/ diff --git a/Makefile b/Makefile index ca31e2f0..4fa37ca6 100644 --- a/Makefile +++ b/Makefile @@ -10,16 +10,16 @@ FRONTEND_DIR := $(ROOT)/interviewhub-lld help: ## Show available commands @awk 'BEGIN {FS = ":.*## "; printf "JudgeMyCode development commands:\n"} /^[a-zA-Z_-]+:.*## / {printf " %-10s %s\n", $$1, $$2}' $(MAKEFILE_LIST) -setup: ## Prepare env files, PostgreSQL, migrations, seeds, and dependencies +setup: ## Prepare env files, database schema, local seeds, and dependencies ./run-local.sh --setup-only -dev: ## Start the complete local development stack +dev: ## Start frontend and API with the configured database mode ./run-local.sh stop: ## Stop API and frontend; leave PostgreSQL running ./stop-local.sh -stop-all: ## Stop API, frontend, and PostgreSQL without deleting data +stop-all: ## Stop API, frontend, and local Docker PostgreSQL ./stop-local.sh --dependencies check: ## Run static checks without producing build artifacts @@ -31,9 +31,10 @@ check: ## Run static checks without producing build artifacts cd "$(FRONTEND_DIR)" && npm run test:session-boundaries docker compose --file "$(BACKEND_DIR)/docker-compose.yml" --project-directory "$(BACKEND_DIR)" config --quiet -test: ## Run backend tests, including the race detector +test: ## Run backend tests and frontend browser regressions cd "$(BACKEND_DIR)" && go test ./... cd "$(BACKEND_DIR)" && go test -race ./... + cd "$(FRONTEND_DIR)" && npm run test:workspace-snapshot build: ## Build backend and production frontend mkdir -p "$(ROOT)/.local-run/bin" diff --git a/README.md b/README.md index 52a1bea4..c64d7d6a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # JudgeMyCode -JudgeMyCode is a React/Vite interview workspace backed by a Go API and PostgreSQL. The supported local workflow runs PostgreSQL in Docker and runs the API and frontend directly on the host for fast reloads. +JudgeMyCode is a React/Vite interview workspace backed by a Go API and PostgreSQL. The supported development workflow can use either loopback-only PostgreSQL in Docker or an explicitly configured managed PostgreSQL database, while the API and frontend run directly on the host. ## One-command local development @@ -8,7 +8,7 @@ Prerequisites: - Go 1.25 or newer compatible toolchain - Node.js 22.12+ and npm -- Docker with Compose v2 +- Docker with Compose v2 when using the default local database mode - `curl`, `lsof`, and `cksum` (included by default on macOS and most Unix systems) - For every local execution language: Python 3, a JDK (`javac`/`java`), and a C++17 compiler (`g++`). Missing toolchains do not prevent the application @@ -20,7 +20,7 @@ From this directory: ./run-local.sh ``` -The command is safe to repeat. It creates missing local environment defaults, starts and waits for PostgreSQL, applies migrations, explicitly loads the local demo users and problem catalog, installs locked frontend dependencies when needed, rebuilds/restarts the API and Vite processes, and waits for both HTTP endpoints. The explicit restart prevents a previous binary from serving stale source or configuration. +The command is safe to repeat. In the default `DATABASE_MODE=local`, it starts and waits for Docker PostgreSQL, applies migrations, and loads local demo users and the problem catalog. With `DATABASE_MODE=remote` (or `--remote-db`), it uses the non-loopback `DATABASE_URL`, skips Docker, applies migrations only, and never automatically seeds users or catalog data. Both modes install locked frontend dependencies when needed, rebuild/restart the API and Vite processes, and wait for both HTTP endpoints. Yes: `run-local.sh` starts the database dependency, backend, and frontend. To start again after stopping—or to rebuild/restart after code changes—run the @@ -37,7 +37,7 @@ seeds, and npm packages prepared without starting the API or frontend. | --- | --- | | Frontend | `http://127.0.0.1:5173` | | API | `http://127.0.0.1:5050/api` | -| PostgreSQL | `127.0.0.1:5432` | +| PostgreSQL | `127.0.0.1:5432` or the configured managed service | Runtime logs are under `logs/`; validated PID files are `backend.pid` and `frontend.pid`. @@ -49,15 +49,41 @@ Runtime logs are under `logs/`; validated PID files are `backend.pid` and `front The Makefile exposes the same workflow: ```bash -make setup # dependencies, database, migrations, local seeds -make dev # complete local stack +make setup # dependencies, configured database schema, local-only seeds +make dev # frontend + API using the configured database mode make stop # application processes only -make stop-all # application + PostgreSQL +make stop-all # application + local Docker PostgreSQL make check # shell, Go, TypeScript, and Compose checks make test # Go tests and race detector make build # API binary and production frontend bundle ``` +## Managed PostgreSQL / Aiven + +Store the development connection URI in `interviewhub-backend-go/.env.local`, which is ignored by Git and excluded from Docker builds. Never put it in frontend variables, `.env.example`, source code, documentation, or logs. A production deployment must inject `DATABASE_URL` through its secret manager rather than copying an env file into the image. + +```dotenv +DATABASE_MODE=remote +DATABASE_URL='postgres://:@:/?sslmode=require' +DATABASE_MAX_OPEN_CONNS=5 +DATABASE_MAX_IDLE_CONNS=2 +DATABASE_CONN_MAX_LIFETIME=15m +DATABASE_CONN_MAX_IDLE_TIME=5m +``` + +The URI must stay quoted if it contains `&`, `$`, spaces, or other shell-significant characters. `sslmode=require` encrypts the connection. For server identity verification, download Aiven's project CA into the ignored `interviewhub-backend-go/.local-secrets/` directory and use `sslmode=verify-full&sslrootcert=/absolute/path/to/aiven-ca.pem`. + +For a new hosted database, initialize the built-in problem catalog once and deliberately; remote full-stack startup otherwise performs schema migrations only: + +```bash +cd interviewhub-backend-go +./scripts/seed-db.sh --skip-migrate +cd .. +./run-local.sh +``` + +Do not use `--users`, `--force-users`, or `--reset-problems` against the hosted database. `./stop-local.sh --dependencies` never stops or deletes a remote database. + ## Repository layout - `interviewhub-backend-go/`: Go API with `internal/httpapi` as the composition/router boundary, embedded SQL migrations, PostgreSQL adapter, and local execution engine. diff --git a/interviewhub-backend-go/.env.example b/interviewhub-backend-go/.env.example index 84c347e6..64b37ea6 100644 --- a/interviewhub-backend-go/.env.example +++ b/interviewhub-backend-go/.env.example @@ -1,4 +1,5 @@ -# Copy to .env locally; inject real values through your deployment platform. +# Copy to .env.local for local development. Inject real values through your +# deployment platform's secret manager outside local development. PORT=5050 HOST=127.0.0.1 APP_ENV=development @@ -34,10 +35,25 @@ LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= LINKEDIN_REDIRECT_URL=http://localhost:5050/api/auth/oauth/linkedin/callback -# Local development database and queue settings. +# DATABASE_MODE is read only by the repository's run-local.sh. "local" starts +# Docker PostgreSQL; "remote" uses DATABASE_URL and never seeds demo users. +DATABASE_MODE=local + # Host-run API (go run ./cmd/server or ./scripts/seed-db.sh): DATABASE_URL=postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable # Docker Compose exposes PostgreSQL only on the loopback interface. +# For Aiven/managed PostgreSQL, keep the URI quoted in .env.local: +# DATABASE_MODE=remote +# DATABASE_URL='postgres://:@:/?sslmode=require' +# Prefer verify-full with the provider CA when available: +# DATABASE_URL='postgres://:@:/?sslmode=verify-full&sslrootcert=/absolute/path/to/aiven-ca.pem' + +# Pool sizes are per API replica. Keep total replica usage below the managed +# service limit, leaving headroom for migrations and operator connections. +DATABASE_MAX_OPEN_CONNS=5 +DATABASE_MAX_IDLE_CONNS=2 +DATABASE_CONN_MAX_LIFETIME=15m +DATABASE_CONN_MAX_IDLE_TIME=5m EXECUTION_ENABLED=false EXECUTION_RUNNER=local diff --git a/interviewhub-backend-go/README.md b/interviewhub-backend-go/README.md index 14a5e075..abcd9acf 100644 --- a/interviewhub-backend-go/README.md +++ b/interviewhub-backend-go/README.md @@ -14,7 +14,7 @@ The supported full-stack command is run from the repository root: ./run-local.sh ``` -It starts loopback-only PostgreSQL, applies embedded migrations, explicitly seeds local demo users and the problem catalog, installs frontend dependencies, and starts the API on `http://127.0.0.1:5050` and Vite on `http://127.0.0.1:5173`. Repeating the command rebuilds and restarts both application processes. +With `DATABASE_MODE=local`, it starts loopback-only PostgreSQL, applies embedded migrations, and explicitly seeds local demo users and the problem catalog. With `DATABASE_MODE=remote` (or `./run-local.sh --remote-db`), it skips Docker and applies migrations without automatic data seeds. Both modes install frontend dependencies and start the API on `http://127.0.0.1:5050` and Vite on `http://127.0.0.1:5173`. Repeating the command rebuilds and restarts both application processes. For backend-only work: @@ -34,6 +34,29 @@ postgres://judgemycode:local-dev-password@127.0.0.1:5432/judgemycode?sslmode=dis Do not use these credentials outside local development. +### Aiven or another managed PostgreSQL service + +Place the service URI only in this directory's Git-ignored `.env.local`: + +```dotenv +DATABASE_MODE=remote +DATABASE_URL='postgres://:@:/?sslmode=require' +DATABASE_MAX_OPEN_CONNS=5 +DATABASE_MAX_IDLE_CONNS=2 +DATABASE_CONN_MAX_LIFETIME=15m +DATABASE_CONN_MAX_IDLE_TIME=5m +``` + +`DATABASE_MODE` controls only the root development launcher. The Go commands use `DATABASE_URL` directly. Remote URLs must enable TLS; `sslmode=require` is accepted, while Aiven's stronger `verify-full` mode also needs its project CA: + +```dotenv +DATABASE_URL='postgres://:@:/?sslmode=verify-full&sslrootcert=/absolute/path/to/aiven-ca.pem' +``` + +Store the downloaded CA under the ignored `.local-secrets/` directory or outside the repository. In production, mount the CA read-only and inject `DATABASE_URL` through the hosting platform's secret manager. + +Remote `run-local.sh` startup applies migrations only and never creates demo users or rewrites catalog data. Initialize the problem catalog once on a fresh development database with `./scripts/seed-db.sh --skip-migrate`. Never use `--users`, `--force-users`, or `--reset-problems` against a hosted database. + ## Checks ```bash diff --git a/interviewhub-backend-go/cmd/oauth-bind/main.go b/interviewhub-backend-go/cmd/oauth-bind/main.go index a9c6b81d..d4b7f76e 100644 --- a/interviewhub-backend-go/cmd/oauth-bind/main.go +++ b/interviewhub-backend-go/cmd/oauth-bind/main.go @@ -41,10 +41,13 @@ func main() { if strings.TrimSpace(cfg.DatabaseURL) == "" { log.Fatal("DATABASE_URL is required") } + if err := cfg.ValidateDatabase(); err != nil { + log.Fatalf("invalid database configuration: %v", err) + } ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() - postgres, err := store.OpenPostgres(ctx, cfg.DatabaseURL) + postgres, err := store.NewPostgres(ctx, cfg) if err != nil { log.Fatalf("connect: %v", err) } diff --git a/interviewhub-backend-go/cmd/seed/main.go b/interviewhub-backend-go/cmd/seed/main.go index 53ddf567..d2da627a 100644 --- a/interviewhub-backend-go/cmd/seed/main.go +++ b/interviewhub-backend-go/cmd/seed/main.go @@ -46,11 +46,14 @@ func main() { if cfg.DatabaseURL == "" { log.Fatal("DATABASE_URL is required. Example:\n DATABASE_URL='postgres://judgemycode:local-dev-password@localhost:5432/judgemycode?sslmode=disable' go run ./cmd/seed") } + if err := cfg.ValidateDatabase(); err != nil { + log.Fatalf("invalid database configuration: %v", err) + } ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() - pg, err := store.OpenPostgres(ctx, cfg.DatabaseURL) + pg, err := store.NewPostgres(ctx, cfg) if err != nil { log.Fatalf("connect: %v", err) } diff --git a/interviewhub-backend-go/docs/production-architecture.md b/interviewhub-backend-go/docs/production-architecture.md index 0d55d4e6..78d86ecd 100644 --- a/interviewhub-backend-go/docs/production-architecture.md +++ b/interviewhub-backend-go/docs/production-architecture.md @@ -66,6 +66,10 @@ and a future isolated judge must enforce independent compile and run budgets. present, list only its exact networks; forwarded addresses from any other peer are ignored. - Use TLS for public traffic and PostgreSQL. Do not use local Compose credentials. +- Size each API replica's PostgreSQL pool with `DATABASE_MAX_OPEN_CONNS`, + `DATABASE_MAX_IDLE_CONNS`, `DATABASE_CONN_MAX_LIFETIME`, and + `DATABASE_CONN_MAX_IDLE_TIME`. The combined replica budget must leave + capacity for migration, monitoring, and operator connections. - Run `/app/seed --migrate-only` from the backend image as a deployment job before rolling out API replicas. Demo users are opt-in local data and forbidden in production. - Before enabling OAuth on an upgraded database, use `/app/oauth-bind` from the backend image (or `go run ./cmd/oauth-bind` from source) to bind diff --git a/interviewhub-backend-go/internal/config/config.go b/interviewhub-backend-go/internal/config/config.go index 0710dd46..be95cf61 100644 --- a/interviewhub-backend-go/internal/config/config.go +++ b/interviewhub-backend-go/internal/config/config.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net" + "net/url" "os" "path/filepath" "strconv" @@ -20,6 +21,10 @@ type Config struct { AccessTokenTTL time.Duration RefreshTokenTTL time.Duration DatabaseURL string + DatabaseMaxOpenConns int + DatabaseMaxIdleConns int + DatabaseConnMaxLifetime time.Duration + DatabaseConnMaxIdleTime time.Duration AppEnv string AllowedOrigins []string TrustedProxyCIDRs []string @@ -82,6 +87,10 @@ func LoadConfig() *Config { AccessTokenTTL: durationFromEnv("ACCESS_TOKEN_TTL", 15*time.Minute), RefreshTokenTTL: durationFromEnv("REFRESH_TOKEN_TTL", 30*24*time.Hour), DatabaseURL: os.Getenv("DATABASE_URL"), + DatabaseMaxOpenConns: intFromEnv("DATABASE_MAX_OPEN_CONNS", 5), + DatabaseMaxIdleConns: intFromEnv("DATABASE_MAX_IDLE_CONNS", 2), + DatabaseConnMaxLifetime: durationFromEnv("DATABASE_CONN_MAX_LIFETIME", 15*time.Minute), + DatabaseConnMaxIdleTime: durationFromEnv("DATABASE_CONN_MAX_IDLE_TIME", 5*time.Minute), AppEnv: environment(), AllowedOrigins: csvEnv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173"), TrustedProxyCIDRs: csvEnv("TRUSTED_PROXY_CIDRS", ""), @@ -143,8 +152,8 @@ func (c *Config) Validate() error { return fmt.Errorf("TRUSTED_PROXY_CIDRS contains invalid CIDR %q", cidr) } } - if c.DatabaseURL == "" { - return errors.New("DATABASE_URL is required; all application data is loaded from PostgreSQL (JSON mock store removed)") + if err := c.ValidateDatabase(); err != nil { + return err } outsideLocalDevelopment := c.IsProduction() || c.AppEnv == "staging" if outsideLocalDevelopment && (c.JWTSecretIsDefault || insecureJWTSecret(c.JWTSecret)) { @@ -206,6 +215,75 @@ func (c *Config) Validate() error { return nil } +// ValidateDatabase checks only persistence configuration so migration and +// operator commands can enforce the same TLS and pool rules without requiring +// unrelated HTTP, JWT, OAuth, or execution settings. +func (c *Config) ValidateDatabase() error { + if strings.TrimSpace(c.DatabaseURL) == "" { + return errors.New("DATABASE_URL is required; all application data is loaded from PostgreSQL (JSON mock store removed)") + } + if err := validateDatabaseURL(c.DatabaseURL); err != nil { + return err + } + if c.DatabaseMaxOpenConns < 1 { + c.DatabaseMaxOpenConns = 5 + } + if c.DatabaseMaxIdleConns < 1 { + c.DatabaseMaxIdleConns = 2 + } + if c.DatabaseMaxIdleConns > c.DatabaseMaxOpenConns { + return errors.New("DATABASE_MAX_IDLE_CONNS must not exceed DATABASE_MAX_OPEN_CONNS") + } + if c.DatabaseConnMaxLifetime <= 0 { + c.DatabaseConnMaxLifetime = 15 * time.Minute + } + if c.DatabaseConnMaxIdleTime <= 0 { + c.DatabaseConnMaxIdleTime = 5 * time.Minute + } + return nil +} + +// validateDatabaseURL prevents a remote PostgreSQL connection from silently +// falling back to an unverified, plaintext-capable SSL mode. Loopback remains +// exempt so the Docker development database can continue using sslmode=disable. +func validateDatabaseURL(databaseURL string) error { + parsed, err := url.Parse(strings.TrimSpace(databaseURL)) + if err != nil { + return errors.New("DATABASE_URL must be a valid PostgreSQL connection URI") + } + if parsed.Scheme != "postgres" && parsed.Scheme != "postgresql" { + return errors.New("DATABASE_URL must use the postgres or postgresql scheme") + } + host := strings.TrimSpace(parsed.Hostname()) + if host == "" { + return errors.New("DATABASE_URL must include a database hostname") + } + if isLoopbackDatabaseHost(host) { + return nil + } + + sslMode := strings.ToLower(strings.TrimSpace(parsed.Query().Get("sslmode"))) + switch sslMode { + case "require": + return nil + case "verify-ca", "verify-full": + if strings.TrimSpace(parsed.Query().Get("sslrootcert")) == "" { + return errors.New("DATABASE_URL using sslmode=verify-ca or verify-full must include sslrootcert") + } + return nil + default: + return errors.New("remote DATABASE_URL must set sslmode=require, verify-ca, or verify-full") + } +} + +func isLoopbackDatabaseHost(host string) bool { + if strings.EqualFold(strings.TrimSpace(host), "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + func insecureJWTSecret(secret string) bool { normalized := strings.ToLower(strings.TrimSpace(secret)) if normalized == "" || normalized == developmentJWTSecret { diff --git a/interviewhub-backend-go/internal/config/config_test.go b/interviewhub-backend-go/internal/config/config_test.go index 44504bb5..2fb1cb70 100644 --- a/interviewhub-backend-go/internal/config/config_test.go +++ b/interviewhub-backend-go/internal/config/config_test.go @@ -120,3 +120,53 @@ func TestValidateRejectsPartialOAuthCredentials(t *testing.T) { t.Fatalf("expected paired OAuth credential error, got %v", err) } } + +func TestValidateAcceptsTLSRemoteDatabase(t *testing.T) { + cfg := validConfig() + cfg.DatabaseURL = "postgres://app:secret@pg.example.test:13582/defaultdb?sslmode=require" + + if err := cfg.Validate(); err != nil { + t.Fatalf("expected valid TLS remote database configuration, got %v", err) + } +} + +func TestValidateRejectsRemoteDatabaseWithoutTLSMode(t *testing.T) { + cfg := validConfig() + cfg.DatabaseURL = "postgres://app:secret@pg.example.test:13582/defaultdb" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "sslmode") { + t.Fatalf("expected remote database SSL mode error, got %v", err) + } +} + +func TestValidateRejectsRemoteDatabaseWithDisabledTLS(t *testing.T) { + cfg := validConfig() + cfg.DatabaseURL = "postgres://app:secret@pg.example.test:13582/defaultdb?sslmode=disable" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "sslmode") { + t.Fatalf("expected remote database TLS error, got %v", err) + } +} + +func TestValidateRequiresRootCertificateForVerifiedTLS(t *testing.T) { + cfg := validConfig() + cfg.DatabaseURL = "postgres://app:secret@pg.example.test:13582/defaultdb?sslmode=verify-full" + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "sslrootcert") { + t.Fatalf("expected root certificate error, got %v", err) + } +} + +func TestValidateRejectsIdlePoolLargerThanOpenPool(t *testing.T) { + cfg := validConfig() + cfg.DatabaseMaxOpenConns = 5 + cfg.DatabaseMaxIdleConns = 6 + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "DATABASE_MAX_IDLE_CONNS") { + t.Fatalf("expected database pool size error, got %v", err) + } +} diff --git a/interviewhub-backend-go/internal/data/seeds_test.go b/interviewhub-backend-go/internal/data/seeds_test.go index 407cdafb..2c381d6e 100644 --- a/interviewhub-backend-go/internal/data/seeds_test.go +++ b/interviewhub-backend-go/internal/data/seeds_test.go @@ -28,8 +28,8 @@ var starterExtensions = map[models.Language]map[string]bool{ func TestSeedProblemsHaveValidLanguageStarterTrees(t *testing.T) { problems := GetSeedProblems() - if len(problems) != 4 { - t.Fatalf("expected 4 seeded problems, got %d", len(problems)) + if len(problems) != 12 { + t.Fatalf("expected 12 seeded problems, got %d", len(problems)) } for _, problem := range problems { diff --git a/interviewhub-backend-go/internal/handlers/discuss_handlers_test.go b/interviewhub-backend-go/internal/handlers/discuss_handlers_test.go index e3820ab6..4b577ead 100644 --- a/interviewhub-backend-go/internal/handlers/discuss_handlers_test.go +++ b/interviewhub-backend-go/internal/handlers/discuss_handlers_test.go @@ -150,7 +150,7 @@ func TestDiscussCreateVoteBookmarkReplyFlow(t *testing.T) { t.Fatalf("stats status=%d body=%s", statsRes.Code, statsRes.Body.String()) } var statsPayload struct { - Success bool `json:"success"` + Success bool `json:"success"` Data models.DiscussionStatsPayload `json:"data"` } if err := json.Unmarshal(statsRes.Body.Bytes(), &statsPayload); err != nil { diff --git a/interviewhub-backend-go/internal/models/discussion.go b/interviewhub-backend-go/internal/models/discussion.go index 707d7b14..eb6094c1 100644 --- a/interviewhub-backend-go/internal/models/discussion.go +++ b/interviewhub-backend-go/internal/models/discussion.go @@ -23,21 +23,21 @@ var DiscussionCategories = []string{ // Discussion is a community post summary/detail payload. type Discussion struct { - ID string `json:"id"` - AuthorID string `json:"authorId"` - AuthorName string `json:"authorName"` - AuthorAvatar string `json:"authorAvatar,omitempty"` - Title string `json:"title"` - Body string `json:"body"` - Category string `json:"category"` - VoteScore int `json:"voteScore"` - ViewCount int `json:"viewCount"` - ReplyCount int `json:"replyCount"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - ViewerVote int `json:"viewerVote,omitempty"` - Bookmarked bool `json:"bookmarked,omitempty"` - Replies []DiscussionReply `json:"replies,omitempty"` + ID string `json:"id"` + AuthorID string `json:"authorId"` + AuthorName string `json:"authorName"` + AuthorAvatar string `json:"authorAvatar,omitempty"` + Title string `json:"title"` + Body string `json:"body"` + Category string `json:"category"` + VoteScore int `json:"voteScore"` + ViewCount int `json:"viewCount"` + ReplyCount int `json:"replyCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ViewerVote int `json:"viewerVote,omitempty"` + Bookmarked bool `json:"bookmarked,omitempty"` + Replies []DiscussionReply `json:"replies,omitempty"` } // DiscussionReply is a comment on a discussion. @@ -91,7 +91,7 @@ type DiscussionCommunityStats struct { // DiscussionStatsPayload is the right-rail widget payload. type DiscussionStatsPayload struct { - TrendingTopics []DiscussionTopicStat `json:"trendingTopics"` - PopularThisWeek []DiscussionPopularItem `json:"popularThisWeek"` - Community DiscussionCommunityStats `json:"community"` + TrendingTopics []DiscussionTopicStat `json:"trendingTopics"` + PopularThisWeek []DiscussionPopularItem `json:"popularThisWeek"` + Community DiscussionCommunityStats `json:"community"` } diff --git a/interviewhub-backend-go/internal/services/discuss_service.go b/interviewhub-backend-go/internal/services/discuss_service.go index 8ed410c4..5d8bed38 100644 --- a/interviewhub-backend-go/internal/services/discuss_service.go +++ b/interviewhub-backend-go/internal/services/discuss_service.go @@ -89,13 +89,13 @@ func (s *DiscussService) Create(authorID string, req models.DiscussionCreateRequ now := time.Now().UTC() created, err := s.store.CreateDiscussion(models.Discussion{ - ID: "dsc_" + uuid.NewString(), - AuthorID: authorID, - Title: title, - Body: body, - Category: category, - CreatedAt: now, - UpdatedAt: now, + ID: "dsc_" + uuid.NewString(), + AuthorID: authorID, + Title: title, + Body: body, + Category: category, + CreatedAt: now, + UpdatedAt: now, }) if err != nil { return models.Discussion{}, serviceError(ErrDependencyUnavailable, "discussion storage is unavailable", err) diff --git a/interviewhub-backend-go/internal/store/postgres.go b/interviewhub-backend-go/internal/store/postgres.go index 6a492ae3..77acf950 100644 --- a/interviewhub-backend-go/internal/store/postgres.go +++ b/interviewhub-backend-go/internal/store/postgres.go @@ -24,6 +24,13 @@ var migrationFS embed.FS // paths are indexed by first-class columns. type PostgresStore struct{ db *sql.DB } +const ( + defaultDatabaseMaxOpenConns = 5 + defaultDatabaseMaxIdleConns = 2 + defaultDatabaseConnMaxLifetime = 15 * time.Minute + defaultDatabaseConnMaxIdleTime = 5 * time.Minute +) + var ( _ Repository = (*PostgresStore)(nil) _ ErrorAwareUserLookup = (*PostgresStore)(nil) @@ -38,16 +45,50 @@ var ( // OpenPostgres connects to DATABASE_URL without migrating or seeding. // Use Seed(...) afterwards when you need schema + catalog population. func OpenPostgres(ctx context.Context, databaseURL string) (*PostgresStore, error) { + return openPostgres( + ctx, + databaseURL, + defaultDatabaseMaxOpenConns, + defaultDatabaseMaxIdleConns, + defaultDatabaseConnMaxLifetime, + defaultDatabaseConnMaxIdleTime, + ) +} + +func openPostgres( + ctx context.Context, + databaseURL string, + maxOpenConns int, + maxIdleConns int, + connMaxLifetime time.Duration, + connMaxIdleTime time.Duration, +) (*PostgresStore, error) { if databaseURL == "" { return nil, errors.New("DATABASE_URL is required for PostgreSQL storage") } + if maxOpenConns < 1 { + maxOpenConns = defaultDatabaseMaxOpenConns + } + if maxIdleConns < 1 { + maxIdleConns = defaultDatabaseMaxIdleConns + } + if maxIdleConns > maxOpenConns { + return nil, errors.New("database max idle connections must not exceed max open connections") + } + if connMaxLifetime <= 0 { + connMaxLifetime = defaultDatabaseConnMaxLifetime + } + if connMaxIdleTime <= 0 { + connMaxIdleTime = defaultDatabaseConnMaxIdleTime + } db, err := sql.Open("pgx", databaseURL) if err != nil { return nil, err } - db.SetMaxOpenConns(20) - db.SetMaxIdleConns(5) - db.SetConnMaxLifetime(30 * time.Minute) + db.SetMaxOpenConns(maxOpenConns) + db.SetMaxIdleConns(maxIdleConns) + db.SetConnMaxLifetime(connMaxLifetime) + db.SetConnMaxIdleTime(connMaxIdleTime) if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, err @@ -59,7 +100,14 @@ func NewPostgres(ctx context.Context, cfg *config.Config) (*PostgresStore, error // Migrations and demo data are deployment concerns, not API process startup // concerns. Run cmd/seed (or the deployment migration job) before booting the // server so replicas never race migrations or create known demo accounts. - return OpenPostgres(ctx, cfg.DatabaseURL) + return openPostgres( + ctx, + cfg.DatabaseURL, + cfg.DatabaseMaxOpenConns, + cfg.DatabaseMaxIdleConns, + cfg.DatabaseConnMaxLifetime, + cfg.DatabaseConnMaxIdleTime, + ) } func (s *PostgresStore) Close() error { return s.db.Close() } diff --git a/interviewhub-backend-go/internal/store/repository.go b/interviewhub-backend-go/internal/store/repository.go index 7334d0bb..0a4ffcee 100644 --- a/interviewhub-backend-go/internal/store/repository.go +++ b/interviewhub-backend-go/internal/store/repository.go @@ -97,10 +97,10 @@ type SubmissionRepository interface { } const ( - DefaultSubmissionPageSize = 50 - MaxSubmissionPageSize = 100 - DefaultDiscussionPageSize = 50 - MaxDiscussionPageSize = 100 + DefaultSubmissionPageSize = 50 + MaxSubmissionPageSize = 100 + DefaultDiscussionPageSize = 50 + MaxDiscussionPageSize = 100 ) // DiscussionListQuery filters and paginates community posts. diff --git a/interviewhub-backend-go/internal/store/store.go b/interviewhub-backend-go/internal/store/store.go index fa0dba2c..88f496e7 100644 --- a/interviewhub-backend-go/internal/store/store.go +++ b/interviewhub-backend-go/internal/store/store.go @@ -20,18 +20,18 @@ var memorySeedTime = time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) // test owns its state and can run safely in parallel. Production uses // PostgresStore (see cmd/server and NewPostgres). type Store struct { - mu sync.RWMutex - users map[string]models.User - problems map[string]models.Problem - submissions map[string]models.Submission - workspaces map[string]models.Workspace - refreshTokens map[string]models.RefreshToken - authCodes map[string]authCodeRecord - oauthIdentities map[string]string - discussions map[string]models.Discussion - replies map[string][]models.DiscussionReply - discussionVotes map[string]int - bookmarks map[string]struct{} + mu sync.RWMutex + users map[string]models.User + problems map[string]models.Problem + submissions map[string]models.Submission + workspaces map[string]models.Workspace + refreshTokens map[string]models.RefreshToken + authCodes map[string]authCodeRecord + oauthIdentities map[string]string + discussions map[string]models.Discussion + replies map[string][]models.DiscussionReply + discussionVotes map[string]int + bookmarks map[string]struct{} // refreshTokenWriteHook is intentionally unexported. Store tests use it to // prove that a failed replacement write cannot partially consume the old diff --git a/interviewhub-lld/README.md b/interviewhub-lld/README.md index 3edaaef2..7561582f 100644 --- a/interviewhub-lld/README.md +++ b/interviewhub-lld/README.md @@ -21,6 +21,7 @@ Vite listens on `http://127.0.0.1:5173` in the root workflow. `VITE_API_BASE_URL ```bash npm run typecheck npm run test:session-boundaries +npm run test:workspace-snapshot npm run build npm run preview ``` @@ -31,6 +32,10 @@ With the local stack running, exercise the real login and workspace route: npm run test:e2e ``` +`test:workspace-snapshot` is a self-contained browser regression for immediate +editor submissions and problem/language transitions. It starts an ephemeral +Vite server and does not connect to the backend or modify database state. + `APP_URL`, `E2E_EMAIL`, `E2E_PASSWORD`, `E2E_SCREENSHOT`, and `PUPPETEER_EXECUTABLE_PATH` are optional overrides. The defaults target the loopback stack and its opt-in local candidate seed; never reuse those demo diff --git a/interviewhub-lld/package.json b/interviewhub-lld/package.json index bd614301..a067b12c 100644 --- a/interviewhub-lld/package.json +++ b/interviewhub-lld/package.json @@ -9,6 +9,7 @@ "build": "npm run typecheck && vite build", "preview": "vite preview", "test:session-boundaries": "node --experimental-strip-types ./scripts/verify-session-boundaries.mjs", + "test:workspace-snapshot": "node ./scripts/verify-workspace-snapshot.mjs", "test:e2e": "node ./scripts/verify-e2e.mjs" }, "dependencies": { diff --git a/interviewhub-lld/scripts/fixtures/workspace-snapshot-harness.html b/interviewhub-lld/scripts/fixtures/workspace-snapshot-harness.html new file mode 100644 index 00000000..25400ea2 --- /dev/null +++ b/interviewhub-lld/scripts/fixtures/workspace-snapshot-harness.html @@ -0,0 +1,13 @@ + + + + + + + Workspace snapshot regression harness + + +
+ + + diff --git a/interviewhub-lld/scripts/fixtures/workspace-snapshot-harness.ts b/interviewhub-lld/scripts/fixtures/workspace-snapshot-harness.ts new file mode 100644 index 00000000..1e2b3284 --- /dev/null +++ b/interviewhub-lld/scripts/fixtures/workspace-snapshot-harness.ts @@ -0,0 +1,153 @@ +import { createElement, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { buildExecutionPayload } from '../../src/features/execution/payload'; +import type { ExecutionPayload } from '../../src/features/execution/api'; +import { useWorkspace } from '../../src/features/workspace/hooks/useWorkspace'; +import type { FileNode, Language, Problem } from '../../src/types'; + +interface SnapshotHarness { + files: FileNode[]; + language: Language; + workspaceReady: boolean; + editAndBuildPayload: (content: string) => ExecutionPayload; + switchLanguage: (language: Language) => void; +} + +interface ScopeTransitionObservation { + from: Language; + to: Language; + blocked: boolean; + error: string; + mutationMarker: string; + createdFileName: string; +} + +declare global { + interface Window { + __workspaceSnapshotHarness?: SnapshotHarness; + __workspaceScopeTransition?: ScopeTransitionObservation; + } +} + +const starterContent = 'public class Main { /* starter */ }'; +const problem: Problem = { + id: 'workspace-snapshot-regression', + title: 'Workspace Snapshot Regression', + subtitle: '', + difficulty: 'Easy', + estimatedTime: '5 min', + companies: [], + description: 'Verifies that execution sees an editor change before React commits its render.', + functionalRequirements: [], + nonFunctionalRequirements: [], + constraints: [], + apis: [], + evaluationCriteria: [], + tips: { title: '', patterns: [], details: [] }, + solutionExplanation: '', + classDiagramMermaid: '', + starterFiles: { + java: [{ + id: 'src-java', + name: 'src', + path: 'src', + isDirectory: true, + children: [{ + id: 'main-java', + name: 'Main.java', + path: 'src/Main.java', + content: starterContent, + isDirectory: false, + language: 'java' + }] + }], + python: [{ + id: 'src-python', + name: 'src', + path: 'src', + isDirectory: true, + children: [{ + id: 'main-python', + name: 'main.py', + path: 'src/main.py', + content: '# python starter', + isDirectory: false, + language: 'python' + }] + }], + go: [], + cpp: [], + typescript: [] + }, + testCases: [] +}; + +const Harness = () => { + const [language, setLanguage] = useState('java'); + const workspace = useWorkspace({ + problem, + language, + authenticated: true, + userId: 'workspace-regression-user' + }); + const previousLanguageRef = useRef(language); + + useLayoutEffect(() => { + const previousLanguage = previousLanguageRef.current; + if (previousLanguage !== language) { + const mutationMarker = '// stale transition mutation'; + const createdFileName = 'StaleTransition.java'; + let blocked = false; + let error = ''; + try { + buildExecutionPayload({ + problemId: problem.id, + language, + getFilesSnapshot: workspace.getFilesSnapshot + }); + } catch (cause) { + blocked = true; + error = cause instanceof Error ? cause.message : String(cause); + } + workspace.changeContent(mutationMarker); + workspace.createFile('src', createdFileName, false); + window.__workspaceScopeTransition = { + from: previousLanguage, + to: language, + blocked, + error, + mutationMarker, + createdFileName + }; + } + previousLanguageRef.current = language; + + window.__workspaceSnapshotHarness = { + files: workspace.files, + language, + workspaceReady: workspace.workspaceReady, + editAndBuildPayload: (content: string) => { + workspace.changeContent(content); + return buildExecutionPayload({ + problemId: problem.id, + language, + getFilesSnapshot: workspace.getFilesSnapshot + }); + }, + switchLanguage: setLanguage + }; + }, [language, workspace]); + + useEffect(() => { + return () => { + delete window.__workspaceSnapshotHarness; + delete window.__workspaceScopeTransition; + }; + }, []); + + return createElement('pre', { id: 'active-content' }, workspace.activeFile?.content || ''); +}; + +const root = document.getElementById('root'); +if (!root) throw new Error('Regression harness root is unavailable'); +createRoot(root).render(createElement(Harness)); diff --git a/interviewhub-lld/scripts/verify-workspace-snapshot.mjs b/interviewhub-lld/scripts/verify-workspace-snapshot.mjs new file mode 100644 index 00000000..288ff48d --- /dev/null +++ b/interviewhub-lld/scripts/verify-workspace-snapshot.mjs @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict'; +import { createServer as createNetServer } from 'node:net'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer'; +import { createServer as createViteServer } from 'vite'; + +const projectRoot = fileURLToPath(new URL('..', import.meta.url)); +const host = '127.0.0.1'; +const originalApiBaseUrl = process.env.VITE_API_BASE_URL; +process.env.VITE_API_BASE_URL = '/api'; +let server; +let browser; + +const reserveLocalPort = () => new Promise((resolve, reject) => { + const probe = createNetServer(); + probe.once('error', reject); + probe.listen(0, host, () => { + const address = probe.address(); + if (!address || typeof address === 'string') { + probe.close(() => reject(new Error('Unable to reserve a local test port'))); + return; + } + probe.close((error) => error ? reject(error) : resolve(address.port)); + }); +}); + +const collectSourceContents = (nodes, contents = []) => { + for (const node of nodes || []) { + if (typeof node.content === 'string') contents.push(node.content); + if (Array.isArray(node.children)) collectSourceContents(node.children, contents); + } + return contents; +}; + +const collectFileNames = (nodes, names = []) => { + for (const node of nodes || []) { + names.push(node.name); + if (Array.isArray(node.children)) collectFileNames(node.children, names); + } + return names; +}; + +try { + const port = await reserveLocalPort(); + server = await createViteServer({ + root: projectRoot, + logLevel: 'error', + server: { host, port, strictPort: true } + }); + await server.listen(); + + const address = server.httpServer?.address(); + if (!address || typeof address === 'string') throw new Error('Vite did not expose a local test port'); + + const launchOptions = { + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] + }; + if (process.env.PUPPETEER_EXECUTABLE_PATH) { + launchOptions.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH; + } + browser = await puppeteer.launch(launchOptions); + const page = await browser.newPage(); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') pageErrors.push(message.text()); + }); + + let resolveJavaWorkspaceRequest; + let resolvePythonWorkspaceRequest; + const javaWorkspaceRequest = new Promise((resolve) => { resolveJavaWorkspaceRequest = resolve; }); + const pythonWorkspaceRequest = new Promise((resolve) => { resolvePythonWorkspaceRequest = resolve; }); + const interceptApiRequest = async (request) => { + const url = new URL(request.url()); + if (!url.pathname.startsWith('/api/')) { + await request.continue(); + return; + } + + if (request.method() === 'GET' && /\/api\/problems\/[^/]+\/workspace$/.test(url.pathname)) { + const language = url.searchParams.get('language'); + if (language === 'java') resolveJavaWorkspaceRequest(request); + else if (language === 'python') resolvePythonWorkspaceRequest(request); + else await request.abort('blockedbyclient'); + return; + } + + if (request.method() === 'PUT' && /\/api\/problems\/[^/]+\/workspace$/.test(url.pathname)) { + const body = JSON.parse(request.postData() || '{}'); + await request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + userId: 'workspace-regression-user', + problemId: 'workspace-snapshot-regression', + ...body, + revision: Number(body.revision || 0) + 1, + updatedAt: new Date().toISOString() + } + }) + }); + return; + } + + await request.abort('blockedbyclient'); + }; + const respondWithEmptyWorkspace = (request, language) => request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { + userId: 'workspace-regression-user', + problemId: 'workspace-snapshot-regression', + language, + files: [], + revision: 0, + updatedAt: new Date().toISOString() + } + }) + }); + + await page.setRequestInterception(true); + page.on('request', interceptApiRequest); + + await page.goto(`http://${host}:${address.port}/scripts/fixtures/workspace-snapshot-harness.html`, { + waitUntil: 'domcontentloaded', + timeout: 20_000 + }); + await page.waitForFunction( + () => Boolean(window.__workspaceSnapshotHarness?.files.length), + { timeout: 10_000, polling: 25 } + ); + const javaRequest = await Promise.race([ + javaWorkspaceRequest, + new Promise((_, reject) => setTimeout(() => reject(new Error('Java workspace request was not observed')), 5_000)) + ]); + const loadingObservation = await page.evaluate(() => { + const harness = window.__workspaceSnapshotHarness; + if (!harness) throw new Error('Workspace snapshot harness is unavailable'); + let error = ''; + try { + harness.editAndBuildPayload('// edit attempted while server draft is loading'); + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause); + } + return { workspaceReady: harness.workspaceReady, error }; + }); + assert.equal(loadingObservation.workspaceReady, false, 'authenticated starter code is provisional until server reconciliation'); + assert.match(loadingObservation.error, /workspace is still loading/i); + await respondWithEmptyWorkspace(javaRequest, 'java'); + await page.waitForFunction( + () => Boolean(window.__workspaceSnapshotHarness?.workspaceReady), + { timeout: 5_000, polling: 25 } + ); + + const marker = `// immediate-submit-${Date.now()}`; + const observation = await page.evaluate((nextContent) => { + const harness = window.__workspaceSnapshotHarness; + if (!harness) throw new Error('Workspace snapshot harness is unavailable'); + const payload = harness.editAndBuildPayload(nextContent); + return { + committedBeforeEdit: harness.files, + submittedFiles: payload.files + }; + }, marker); + + assert.ok( + collectSourceContents(observation.committedBeforeEdit).some((content) => content.includes('starter')), + 'the test must begin from nested starter code' + ); + assert.ok( + collectSourceContents(observation.submittedFiles).includes(marker), + 'an immediate execution payload must contain the latest editor content' + ); + await page.waitForFunction( + (expected) => document.querySelector('#active-content')?.textContent === expected, + { timeout: 5_000, polling: 25 }, + marker + ); + + await page.evaluate(() => { + const harness = window.__workspaceSnapshotHarness; + if (!harness) throw new Error('Workspace snapshot harness is unavailable'); + harness.switchLanguage('python'); + }); + const pythonRequest = await Promise.race([ + pythonWorkspaceRequest, + new Promise((_, reject) => setTimeout(() => reject(new Error('Python workspace request was not observed')), 5_000)) + ]); + await page.waitForFunction( + () => window.__workspaceSnapshotHarness?.language === 'python', + { timeout: 5_000, polling: 25 } + ); + const pendingTransition = await page.evaluate(() => ({ + observation: window.__workspaceScopeTransition, + workspaceReady: window.__workspaceSnapshotHarness?.workspaceReady + })); + assert.equal(pendingTransition.workspaceReady, false, 'the next language remains blocked during server reconciliation'); + assert.equal(pendingTransition.observation?.from, 'java'); + assert.equal(pendingTransition.observation?.to, 'python'); + assert.equal( + pendingTransition.observation?.blocked, + true, + 'execution must be blocked between a scope change and workspace hydration' + ); + assert.match(pendingTransition.observation?.error || '', /workspace is still loading/i); + + await respondWithEmptyWorkspace(pythonRequest, 'python'); + await page.waitForFunction( + () => Boolean( + window.__workspaceSnapshotHarness?.language === 'python' && + window.__workspaceSnapshotHarness.workspaceReady + ), + { timeout: 5_000, polling: 25 } + ); + const transition = await page.evaluate(() => ({ + observation: window.__workspaceScopeTransition, + files: window.__workspaceSnapshotHarness?.files || [] + })); + assert.ok( + collectSourceContents(transition.files).includes('# python starter'), + 'the hydrated language must expose only its own source tree' + ); + assert.ok( + !collectSourceContents(transition.files).includes(transition.observation?.mutationMarker), + 'an editor event during scope hydration must not mutate the next workspace' + ); + assert.ok( + !collectFileNames(transition.files).includes(transition.observation?.createdFileName), + 'a file creation during scope hydration must not mutate the next workspace' + ); + assert.deepEqual(pageErrors, [], `browser errors:\n${pageErrors.join('\n')}`); + + process.stdout.write('Immediate workspace snapshot and scope-transition regressions passed.\n'); +} finally { + await browser?.close(); + await server?.close(); + if (originalApiBaseUrl === undefined) delete process.env.VITE_API_BASE_URL; + else process.env.VITE_API_BASE_URL = originalApiBaseUrl; +} diff --git a/interviewhub-lld/src/app/App.tsx b/interviewhub-lld/src/app/App.tsx index cdbffd08..718606a6 100644 --- a/interviewhub-lld/src/app/App.tsx +++ b/interviewhub-lld/src/app/App.tsx @@ -36,6 +36,7 @@ import { api } from '../services/apiClient'; import { useSession } from '../features/auth/useSession'; import { useExecutionJob } from '../features/execution/hooks/useExecutionJob'; import type { ExecutionResult } from '../features/execution/api'; +import { buildExecutionPayload } from '../features/execution/payload'; import { useWorkspace } from '../features/workspace/hooks/useWorkspace'; import { defaultFileName, findNodeByPath } from '../features/workspace/lib/fileTree'; import { tokenStore } from '../shared/api'; @@ -166,6 +167,8 @@ export const App: React.FC = () => { currentLanguageRef.current = currentLanguage; const { files, + getFilesSnapshot, + workspaceReady, openFiles, activeFile, saveStatus, @@ -400,10 +403,10 @@ export const App: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [currentView]); - const executionPayload = (testCaseId?: string, runTests = false) => ({ + const executionPayload = (testCaseId?: string, runTests = false) => buildExecutionPayload({ problemId: currentProblem?.id || '', language: currentLanguage, - files, + getFilesSnapshot, customInput, testCaseId, runTests @@ -819,6 +822,7 @@ export const App: React.FC = () => { fontSize={fontSize} onChangeFontSize={setFontSize} isRunning={isRunning} + isWorkspaceReady={workspaceReady} onRunCode={handleRunCode} onSubmitCode={handleSubmitCode} isAiOpen={isAiOpen} @@ -1027,6 +1031,7 @@ export const App: React.FC = () => { language={currentLanguage} fontSize={fontSize} theme={theme} + readOnly={!workspaceReady} onChangeContent={handleContentChange} onCursorChange={(line, col) => setCursorPos({ line, col })} onOpenFileAt={handleOpenFileAt} diff --git a/interviewhub-lld/src/components/CodeEditor.tsx b/interviewhub-lld/src/components/CodeEditor.tsx index 785db7a1..be193973 100644 --- a/interviewhub-lld/src/components/CodeEditor.tsx +++ b/interviewhub-lld/src/components/CodeEditor.tsx @@ -10,6 +10,7 @@ interface CodeEditorProps { language: Language; fontSize: number; theme: 'dark' | 'light'; + readOnly?: boolean; onChangeContent: (newContent: string) => void; onCursorChange?: (line: number, col: number) => void; onOpenFileAt?: (path: string, line?: number, column?: number) => void; @@ -101,6 +102,7 @@ export const CodeEditor: React.FC = ({ language, fontSize, theme, + readOnly = false, onChangeContent, onCursorChange, onOpenFileAt @@ -346,6 +348,7 @@ export const CodeEditor: React.FC = ({ } options={{ + readOnly, fontSize, fontFamily: "'JetBrains Mono', 'Fira Code', Menlo, Monaco, Consolas, monospace", fontLigatures: true, diff --git a/interviewhub-lld/src/components/TopNav.tsx b/interviewhub-lld/src/components/TopNav.tsx index 85281933..7b4dbfed 100644 --- a/interviewhub-lld/src/components/TopNav.tsx +++ b/interviewhub-lld/src/components/TopNav.tsx @@ -30,6 +30,7 @@ interface TopNavProps { fontSize: number; onChangeFontSize: (size: number) => void; isRunning: boolean; + isWorkspaceReady: boolean; onRunCode: () => void; onSubmitCode: () => void; isAiOpen: boolean; @@ -52,6 +53,7 @@ export const TopNav: React.FC = ({ fontSize, onChangeFontSize, isRunning, + isWorkspaceReady, onRunCode, onSubmitCode, isAiOpen, @@ -256,7 +258,8 @@ export const TopNav: React.FC = ({