exclusion list apply logic
This commit is contained in:
@@ -92,10 +92,11 @@ All FastAPI route handlers must declare explicit `operation_id` to control the g
|
|||||||
Generated from the backend OpenAPI spec using `@hey-api/openapi-ts`:
|
Generated from the backend OpenAPI spec using `@hey-api/openapi-ts`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && uv run python -c "import json; from app.main import app; json.dump(app.openapi(), open('openapi.json', 'w'))"
|
just generate-client
|
||||||
cd ../frontend && npx @hey-api/openapi-ts -i ../backend/openapi.json -o src/lib/api
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This runs the full pipeline: exports the OpenAPI spec from the running FastAPI app and regenerates the TypeScript SDK in `frontend/src/lib/api/`. Use this **after any backend change** that adds, renames, or modifies endpoints or schemas.
|
||||||
|
|
||||||
The generated SDK exports clean camelCase functions (e.g., `getDashboardStats`, `listJobs`, `triggerScan`).
|
The generated SDK exports clean camelCase functions (e.g., `getDashboardStats`, `listJobs`, `triggerScan`).
|
||||||
|
|
||||||
**Rule:** After renaming any backend handler or changing an `operation_id`, regenerate the SDK and update all frontend imports. The old verbose names will cause TypeScript errors.
|
**Rule:** After renaming any backend handler or changing an `operation_id`, regenerate the SDK and update all frontend imports. The old verbose names will cause TypeScript errors.
|
||||||
@@ -151,10 +152,19 @@ On macOS, `localhost` resolves to `::1` (IPv6) by default, but uvicorn may bind
|
|||||||
7. Add backend tests in `backend/tests/test_api_system.py` (or a new test file if it's a new domain).
|
7. Add backend tests in `backend/tests/test_api_system.py` (or a new test file if it's a new domain).
|
||||||
8. Run `just lint` before finishing.
|
8. Run `just lint` before finishing.
|
||||||
|
|
||||||
### Regenerating the OpenAPI Spec
|
### Regenerating the OpenAPI Spec / TypeScript SDK
|
||||||
|
|
||||||
|
Use the convenience command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just generate-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run the steps manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && uv run python -c "import json; from app.main import app; json.dump(app.openapi(), open('openapi.json', 'w'), indent=2)"
|
cd backend && uv run python -c "import json; from app.main import app; json.dump(app.openapi(), open('openapi.json', 'w'), indent=2)"
|
||||||
|
cd ../frontend && npx @hey-api/openapi-ts -i ../backend/openapi.json -o src/lib/api
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verifying No Auto-Generated operationIds
|
### Verifying No Auto-Generated operationIds
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""add is_ignored_by_policy to filesystem_state
|
||||||
|
|
||||||
|
Revision ID: 806e933ac89b
|
||||||
|
Revises: 349e61f9e856
|
||||||
|
Create Date: 2026-05-04 19:34:38.280865
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "806e933ac89b"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "349e61f9e856"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# Check if column already exists (e.g. from manual migration)
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns("filesystem_state")]
|
||||||
|
|
||||||
|
if "is_ignored_by_policy" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
"filesystem_state",
|
||||||
|
sa.Column(
|
||||||
|
"is_ignored_by_policy", sa.Boolean(), nullable=False, server_default="0"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create index for efficient querying
|
||||||
|
indexes = [idx["name"] for idx in inspector.get_indexes("filesystem_state")]
|
||||||
|
if "ix_filesystem_state_is_ignored_by_policy" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_filesystem_state_is_ignored_by_policy",
|
||||||
|
"filesystem_state",
|
||||||
|
["is_ignored_by_policy"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill: set is_ignored_by_policy = is_ignored for existing records
|
||||||
|
op.execute("UPDATE filesystem_state SET is_ignored_by_policy = is_ignored")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_index(
|
||||||
|
"ix_filesystem_state_is_ignored_by_policy", table_name="filesystem_state"
|
||||||
|
)
|
||||||
|
op.drop_column("filesystem_state", "is_ignored_by_policy")
|
||||||
@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
import pathspec
|
import pathspec
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import models
|
from app.db import models
|
||||||
@@ -82,6 +83,41 @@ def get_ignored_status(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_ignored_by_policy(
|
||||||
|
absolute_path: str,
|
||||||
|
exclusion_spec: Optional[pathspec.PathSpec],
|
||||||
|
) -> bool:
|
||||||
|
"""Determines if a path is excluded by global policy only (ignores manual tracking rules)."""
|
||||||
|
if exclusion_spec and exclusion_spec.match_file(absolute_path):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_exclusion_policy(db_session: Session) -> None:
|
||||||
|
"""Recomputes is_ignored_by_policy and effective is_ignored for all indexed files."""
|
||||||
|
exclusion_spec = get_exclusion_spec(db_session)
|
||||||
|
tracking_rules = db_session.query(models.TrackedSource).all()
|
||||||
|
tracking_map = {rule.path: rule.action for rule in tracking_rules}
|
||||||
|
|
||||||
|
# Update is_ignored_by_policy in batches
|
||||||
|
all_files = db_session.query(
|
||||||
|
models.FilesystemState.id, models.FilesystemState.file_path
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for file_id, file_path in all_files:
|
||||||
|
is_ignored_by_policy = get_ignored_by_policy(file_path, exclusion_spec)
|
||||||
|
is_ignored = get_ignored_status(file_path, tracking_map, exclusion_spec)
|
||||||
|
|
||||||
|
db_session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE filesystem_state SET is_ignored_by_policy = :policy, is_ignored = :ignored WHERE id = :id"
|
||||||
|
),
|
||||||
|
{"policy": is_ignored_by_policy, "ignored": is_ignored, "id": file_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
def _validate_path_within_roots(path: str, roots: List[str]) -> bool:
|
def _validate_path_within_roots(path: str, roots: List[str]) -> bool:
|
||||||
"""Validates that a path does not contain traversal sequences and is within configured roots."""
|
"""Validates that a path does not contain traversal sequences and is within configured roots."""
|
||||||
if ".." in path:
|
if ".." in path:
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ def browse_system_path(
|
|||||||
entry_path = entry.path
|
entry_path = entry.path
|
||||||
is_dir = entry.is_dir()
|
is_dir = entry.is_dir()
|
||||||
is_ignored = get_ignored_status(
|
is_ignored = get_ignored_status(
|
||||||
entry_path, tracking_map, exclusion_spec
|
entry_path + "/" if is_dir else entry_path,
|
||||||
|
tracking_map,
|
||||||
|
exclusion_spec,
|
||||||
)
|
)
|
||||||
if is_dir:
|
if is_dir:
|
||||||
live_results.append(
|
live_results.append(
|
||||||
@@ -129,7 +131,7 @@ def browse_system_path(
|
|||||||
if child_path not in seen:
|
if child_path not in seen:
|
||||||
seen.add(child_path)
|
seen.add(child_path)
|
||||||
dir_ignored = get_ignored_status(
|
dir_ignored = get_ignored_status(
|
||||||
child_path, tracking_map, exclusion_spec
|
child_path + "/", tracking_map, exclusion_spec
|
||||||
)
|
)
|
||||||
results.append(
|
results.append(
|
||||||
FileItemSchema(
|
FileItemSchema(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.common import FileItemSchema, SettingSchema
|
from app.api.common import FileItemSchema, SettingSchema, recompute_exclusion_policy
|
||||||
from app.db import models
|
from app.db import models
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
|
|
||||||
@@ -57,6 +57,10 @@ def update_settings(setting_data: SettingSchema, db_session: Session = Depends(g
|
|||||||
|
|
||||||
scheduler_manager.reload()
|
scheduler_manager.reload()
|
||||||
|
|
||||||
|
# Recompute exclusion policy when global exclusions change
|
||||||
|
if setting_data.key == "global_exclusions":
|
||||||
|
recompute_exclusion_policy(db_session)
|
||||||
|
|
||||||
return {"message": "Setting committed."}
|
return {"message": "Setting committed."}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ class FilesystemState(Base):
|
|||||||
)
|
)
|
||||||
is_ignored: Mapped[bool] = mapped_column(
|
is_ignored: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False
|
Boolean, default=False
|
||||||
) # True if matches exclusion
|
) # Effective ignored state (manual OR policy, with manual override)
|
||||||
|
is_ignored_by_policy: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False
|
||||||
|
) # True if excluded by global policy (excludes manual tracking rules)
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False
|
Boolean, default=False
|
||||||
) # True if confirmed missing from disk
|
) # True if confirmed missing from disk
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -676,6 +676,32 @@ export type TestExclusionsRequest = {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TestExclusionsResponse
|
||||||
|
*/
|
||||||
|
export type TestExclusionsResponse = {
|
||||||
|
/**
|
||||||
|
* Total Files
|
||||||
|
*/
|
||||||
|
total_files: number;
|
||||||
|
/**
|
||||||
|
* Total Size
|
||||||
|
*/
|
||||||
|
total_size: number;
|
||||||
|
/**
|
||||||
|
* Matched Count
|
||||||
|
*/
|
||||||
|
matched_count: number;
|
||||||
|
/**
|
||||||
|
* Matched Size
|
||||||
|
*/
|
||||||
|
matched_size: number;
|
||||||
|
/**
|
||||||
|
* Sample
|
||||||
|
*/
|
||||||
|
sample: Array<FileItemSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TestNotificationRequest
|
* TestNotificationRequest
|
||||||
*/
|
*/
|
||||||
@@ -1185,7 +1211,7 @@ export type GetSettingsResponses = {
|
|||||||
* Successful Response
|
* Successful Response
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
[key: string]: unknown;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1234,9 +1260,11 @@ export type TestExclusionsResponses = {
|
|||||||
/**
|
/**
|
||||||
* Successful Response
|
* Successful Response
|
||||||
*/
|
*/
|
||||||
200: unknown;
|
200: TestExclusionsResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TestExclusionsResponse2 = TestExclusionsResponses[keyof TestExclusionsResponses];
|
||||||
|
|
||||||
export type DownloadExclusionReportData = {
|
export type DownloadExclusionReportData = {
|
||||||
body: TestExclusionsRequest;
|
body: TestExclusionsRequest;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { API_URL, SOURCE_ROOT, setupRequestContext, triggerScanAndWait } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Exclusion Policy', () => {
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
fs.mkdirSync(SOURCE_ROOT, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(SOURCE_ROOT, 'docs'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(SOURCE_ROOT, 'temp'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(SOURCE_ROOT, 'readme.txt'), 'hello');
|
||||||
|
fs.writeFileSync(path.join(SOURCE_ROOT, 'notes.txt'), 'world');
|
||||||
|
fs.writeFileSync(path.join(SOURCE_ROOT, 'data.tmp'), 'temp1');
|
||||||
|
fs.writeFileSync(path.join(SOURCE_ROOT, 'cache.tmp'), 'temp2');
|
||||||
|
fs.writeFileSync(path.join(SOURCE_ROOT, 'docs', 'guide.txt'), 'guide');
|
||||||
|
fs.writeFileSync(path.join(SOURCE_ROOT, 'docs', 'draft.tmp'), 'draft');
|
||||||
|
fs.writeFileSync(path.join(SOURCE_ROOT, 'temp', 'scratch.tmp'), 'scratch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global exclusions mark matching files as ignored', async ({ page }) => {
|
||||||
|
const requestContext = await setupRequestContext();
|
||||||
|
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'source_roots', value: JSON.stringify([SOURCE_ROOT]) }
|
||||||
|
});
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'global_exclusions', value: '*.tmp\n' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerScanAndWait(requestContext);
|
||||||
|
|
||||||
|
const browseResp = await requestContext.get(
|
||||||
|
`${API_URL}/system/browse?path=${SOURCE_ROOT}`
|
||||||
|
);
|
||||||
|
const browseData = await browseResp.json();
|
||||||
|
const files = (browseData as any).files;
|
||||||
|
|
||||||
|
const tmpFiles = (files as Array<any>).filter((f: any) => f.name.endsWith('.tmp'));
|
||||||
|
const txtFiles = (files as Array<any>).filter((f: any) => f.name.endsWith('.txt'));
|
||||||
|
|
||||||
|
expect(tmpFiles.length).toBeGreaterThan(0);
|
||||||
|
tmpFiles.forEach((f: any) => {
|
||||||
|
expect(f.ignored, `expected ${f.name} to be ignored`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
txtFiles.forEach((f: any) => {
|
||||||
|
expect(f.ignored, `expected ${f.name} to NOT be ignored`).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual include overrides global exclusion', async ({ page }) => {
|
||||||
|
const requestContext = await setupRequestContext();
|
||||||
|
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'source_roots', value: JSON.stringify([SOURCE_ROOT]) }
|
||||||
|
});
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'global_exclusions', value: '*.tmp\n' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include one specific .tmp file despite the global exclusion
|
||||||
|
await requestContext.post(`${API_URL}/system/track/batch`, {
|
||||||
|
data: {
|
||||||
|
tracks: [path.join(SOURCE_ROOT, 'data.tmp')],
|
||||||
|
untracks: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerScanAndWait(requestContext);
|
||||||
|
|
||||||
|
const browseResp = await requestContext.get(
|
||||||
|
`${API_URL}/system/browse?path=${SOURCE_ROOT}`
|
||||||
|
);
|
||||||
|
const browseData = await browseResp.json();
|
||||||
|
const files = (browseData as any).files;
|
||||||
|
|
||||||
|
const dataTmp = (files as Array<any>).find((f: any) => f.name === 'data.tmp');
|
||||||
|
const cacheTmp = (files as Array<any>).find((f: any) => f.name === 'cache.tmp');
|
||||||
|
|
||||||
|
expect(dataTmp).toBeDefined();
|
||||||
|
expect(dataTmp.ignored).toBe(false);
|
||||||
|
|
||||||
|
expect(cacheTmp).toBeDefined();
|
||||||
|
expect(cacheTmp.ignored).toBe(true);
|
||||||
|
|
||||||
|
await requestContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updating global exclusions recomputes existing indexed files', async ({ page }) => {
|
||||||
|
const requestContext = await setupRequestContext();
|
||||||
|
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'source_roots', value: JSON.stringify([SOURCE_ROOT]) }
|
||||||
|
});
|
||||||
|
// No exclusions initially
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'global_exclusions', value: '' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerScanAndWait(requestContext);
|
||||||
|
|
||||||
|
// Verify nothing is ignored before exclusions are set
|
||||||
|
const browseBefore = await requestContext.get(
|
||||||
|
`${API_URL}/system/browse?path=${SOURCE_ROOT}`
|
||||||
|
);
|
||||||
|
const beforeData = await browseBefore.json();
|
||||||
|
const beforeFiles = (beforeData as any).files as Array<any>;
|
||||||
|
beforeFiles.forEach((f: any) => {
|
||||||
|
expect(f.ignored, `expected ${f.name} to NOT be ignored before policy`).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now apply global exclusions — should recompute without requiring a new scan
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'global_exclusions', value: '*.tmp\n' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const browseAfter = await requestContext.get(
|
||||||
|
`${API_URL}/system/browse?path=${SOURCE_ROOT}`
|
||||||
|
);
|
||||||
|
const afterData = await browseAfter.json();
|
||||||
|
const afterFiles = (afterData as any).files as Array<any>;
|
||||||
|
|
||||||
|
const tmpAfter = afterFiles.filter((f: any) => f.name.endsWith('.tmp'));
|
||||||
|
const txtAfter = afterFiles.filter((f: any) => f.name.endsWith('.txt'));
|
||||||
|
|
||||||
|
tmpAfter.forEach((f: any) => {
|
||||||
|
expect(f.ignored, `expected ${f.name} to be ignored after policy update`).toBe(true);
|
||||||
|
});
|
||||||
|
txtAfter.forEach((f: any) => {
|
||||||
|
expect(f.ignored, `expected ${f.name} to NOT be ignored after policy update`).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusion preview returns correct counts and sample', async ({ page }) => {
|
||||||
|
const requestContext = await setupRequestContext();
|
||||||
|
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'source_roots', value: JSON.stringify([SOURCE_ROOT]) }
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerScanAndWait(requestContext);
|
||||||
|
|
||||||
|
const previewResp = await requestContext.post(`${API_URL}/system/settings/test-exclusions`, {
|
||||||
|
data: { patterns: '*.tmp', limit: 10 }
|
||||||
|
});
|
||||||
|
expect(previewResp.ok()).toBe(true);
|
||||||
|
const preview = await previewResp.json();
|
||||||
|
|
||||||
|
expect(preview.total_files).toBeGreaterThan(0);
|
||||||
|
expect(preview.matched_count).toBeGreaterThan(0);
|
||||||
|
expect(preview.matched_size).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(Array.isArray(preview.sample)).toBe(true);
|
||||||
|
expect(preview.sample.length).toBeGreaterThan(0);
|
||||||
|
expect(preview.sample.length).toBeLessThanOrEqual(10);
|
||||||
|
|
||||||
|
preview.sample.forEach((s: any) => {
|
||||||
|
expect(s.name.endsWith('.tmp')).toBe(true);
|
||||||
|
expect(s.path).toBeDefined();
|
||||||
|
expect(s.size).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
await requestContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusion preview with no patterns returns empty result', async ({ page }) => {
|
||||||
|
const requestContext = await setupRequestContext();
|
||||||
|
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'source_roots', value: JSON.stringify([SOURCE_ROOT]) }
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerScanAndWait(requestContext);
|
||||||
|
|
||||||
|
const previewResp = await requestContext.post(`${API_URL}/system/settings/test-exclusions`, {
|
||||||
|
data: { patterns: '', limit: 10 }
|
||||||
|
});
|
||||||
|
expect(previewResp.ok()).toBe(true);
|
||||||
|
const preview = await previewResp.json();
|
||||||
|
|
||||||
|
expect(preview.total_files).toBe(0);
|
||||||
|
expect(preview.matched_count).toBe(0);
|
||||||
|
expect(preview.matched_size).toBe(0);
|
||||||
|
expect(preview.sample).toEqual([]);
|
||||||
|
|
||||||
|
await requestContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusion CSV download contains matched files', async ({ page }) => {
|
||||||
|
const requestContext = await setupRequestContext();
|
||||||
|
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'source_roots', value: JSON.stringify([SOURCE_ROOT]) }
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerScanAndWait(requestContext);
|
||||||
|
|
||||||
|
const downloadResp = await requestContext.post(
|
||||||
|
`${API_URL}/system/settings/test-exclusions/download`,
|
||||||
|
{ data: { patterns: '*.tmp' } }
|
||||||
|
);
|
||||||
|
expect(downloadResp.ok()).toBe(true);
|
||||||
|
|
||||||
|
const contentType = downloadResp.headers()['content-type'];
|
||||||
|
expect(contentType).toContain('text/csv');
|
||||||
|
|
||||||
|
const body = await downloadResp.text();
|
||||||
|
expect(body).toContain('path,size,mtime,sha256_hash');
|
||||||
|
expect(body).toContain('.tmp');
|
||||||
|
|
||||||
|
const lines = body.trim().split('\n');
|
||||||
|
expect(lines.length).toBeGreaterThan(1); // header + at least one row
|
||||||
|
|
||||||
|
await requestContext.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directory-level global exclusion ignores nested files', async ({ page }) => {
|
||||||
|
const requestContext = await setupRequestContext();
|
||||||
|
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'source_roots', value: JSON.stringify([SOURCE_ROOT]) }
|
||||||
|
});
|
||||||
|
await requestContext.post(`${API_URL}/system/settings`, {
|
||||||
|
data: { key: 'global_exclusions', value: 'temp/\n' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerScanAndWait(requestContext);
|
||||||
|
|
||||||
|
const browseRoot = await requestContext.get(
|
||||||
|
`${API_URL}/system/browse?path=${SOURCE_ROOT}`
|
||||||
|
);
|
||||||
|
const rootData = await browseRoot.json();
|
||||||
|
const rootFiles = (rootData as any).files as Array<any>;
|
||||||
|
|
||||||
|
const tempDir = rootFiles.find((f: any) => f.name === 'temp');
|
||||||
|
expect(tempDir).toBeDefined();
|
||||||
|
expect(tempDir.ignored).toBe(true);
|
||||||
|
|
||||||
|
// Files inside temp should also be ignored
|
||||||
|
const browseTemp = await requestContext.get(
|
||||||
|
`${API_URL}/system/browse?path=${path.join(SOURCE_ROOT, 'temp')}`
|
||||||
|
);
|
||||||
|
const tempData = await browseTemp.json();
|
||||||
|
const tempFiles = (tempData as any).files as Array<any>;
|
||||||
|
tempFiles.forEach((f: any) => {
|
||||||
|
expect(f.ignored, `expected ${f.name} inside temp/ to be ignored`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Files outside temp should NOT be ignored
|
||||||
|
const readme = rootFiles.find((f: any) => f.name === 'readme.txt');
|
||||||
|
expect(readme).toBeDefined();
|
||||||
|
expect(readme.ignored).toBe(false);
|
||||||
|
|
||||||
|
await requestContext.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,12 +64,25 @@ db-migrate message:
|
|||||||
|
|
||||||
# --- Code Generation ---
|
# --- Code Generation ---
|
||||||
|
|
||||||
|
# Export the OpenAPI spec JSON without regenerating the TypeScript client
|
||||||
|
export-openapi:
|
||||||
|
@echo "Exporting OpenAPI spec..."
|
||||||
|
@cd backend && uv run python scripts/generate_openapi.py /tmp/tapehoard_openapi.json
|
||||||
|
|
||||||
# Generate the TypeScript API client from the FastAPI OpenAPI spec
|
# Generate the TypeScript API client from the FastAPI OpenAPI spec
|
||||||
generate-client: db-upgrade
|
generate-client: db-upgrade
|
||||||
@echo "Generating TypeScript API client..."
|
@echo "Generating TypeScript API client..."
|
||||||
@cd backend && uv run python scripts/generate_openapi.py /tmp/tapehoard_openapi.json
|
@cd backend && uv run python scripts/generate_openapi.py /tmp/tapehoard_openapi.json
|
||||||
@cd frontend && npx @hey-api/openapi-ts -i /tmp/tapehoard_openapi.json -o src/lib/api -c @hey-api/client-fetch
|
@cd frontend && npx @hey-api/openapi-ts -i /tmp/tapehoard_openapi.json -o src/lib/api -c @hey-api/client-fetch
|
||||||
|
|
||||||
|
# Full regeneration workflow after schema changes: migrate, upgrade, generate client, lint
|
||||||
|
regenerate message: db-upgrade
|
||||||
|
@echo "Running full regeneration workflow..."
|
||||||
|
cd backend && uv run alembic revision --autogenerate -m "{{message}}"
|
||||||
|
cd backend && uv run alembic upgrade head
|
||||||
|
@just generate-client
|
||||||
|
@just lint
|
||||||
|
|
||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
|
|
||||||
# Build the production Docker image
|
# Build the production Docker image
|
||||||
@@ -97,3 +110,10 @@ playwright:
|
|||||||
playwright-ui:
|
playwright-ui:
|
||||||
@echo "Starting playweight UI..."
|
@echo "Starting playweight UI..."
|
||||||
cd frontend && npx playwright test --ui
|
cd frontend && npx playwright test --ui
|
||||||
|
|
||||||
|
# Clean test artifacts and kill stale test servers
|
||||||
|
clean-test:
|
||||||
|
@echo "Cleaning test artifacts..."
|
||||||
|
pkill -f "start_test_server" 2>/dev/null || true
|
||||||
|
rm -f backend/e2e_test.db backend/e2e_test.db-*
|
||||||
|
rm -rf frontend/test-results/
|
||||||
|
|||||||
Reference in New Issue
Block a user