Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/@nitpicker/crawler/src/archive/database.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3443,6 +3443,47 @@ describe('getJSON (getConfig 経由)', () => {

await db2.destroy();
});

it('info.maxExcludedDepth が NULL の場合フォールバック値 0 を返す(issue #261)', async () => {
// `maxExcludedDepth` is a plain integer column, not a JSON column
// like `excludes`, so it needs its own NULL-fallback coverage —
// getJSON's fallback path above does not exercise it.
const nullDepthDbPath = path.resolve(
workingDir,
'null-max-excluded-depth-test.sqlite',
);
const db = await Database.connect({ filename: nullDepthDbPath });

const config: Config = {
version: '0.13.0',
name: 'test',
baseUrl: 'https://example.com',
roots: ['https://example.com'],
recursive: false,
interval: 500,
image: false,
fetchExternal: false,
parallels: 1,
excludes: [],
excludeKeywords: [],
excludeUrls: [],
maxExcludedDepth: 3,
retry: 3,
fromList: false,
disableQueries: false,
userAgent: 'test',
ignoreRobots: false,
};

await db.setConfig(config);
await db.getKnex()('info').update({ maxExcludedDepth: null });

const retrieved = await db.getConfig();
expect(retrieved.maxExcludedDepth).toBe(0);

await db.destroy();
await remove(nullDepthDbPath);
});
});

describe('insertPageError', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function getConfig(knex: Knex): Promise<Config> {
excludeUrls: getJSON<string[]>(config.excludeUrls, []),
roots: getJSON<string[]>(config.roots, []),
retry: config.retry ?? 3,
maxExcludedDepth: config.maxExcludedDepth ?? 0,
};
// @ts-expect-error — `id` is the primary key, not part of the public Config shape
delete opt.id;
Expand Down
6 changes: 6 additions & 0 deletions packages/@nitpicker/query/src/get-summary-fast-path.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ function makeSummary(baseUrl: string): SummaryResult {
return {
baseUrl,
roots: [],
excludes: [],
excludeKeywords: [],
excludeUrls: [],
maxExcludedDepth: 0,
totalPages: 0,
internalPages: 0,
externalPages: 0,
Expand All @@ -40,6 +44,8 @@ function makeSummary(baseUrl: string): SummaryResult {
ogImage: 0,
},
contentTypeDistribution: [],
networkOutageAffectedFailures: 0,
consoleLogCounts: { pageerror: 0, error: 0, warn: 0 },
};
}

Expand Down
39 changes: 39 additions & 0 deletions packages/@nitpicker/query/src/get-summary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1106,3 +1106,42 @@ describe('getSummary: console log counts (issue #228)', () => {
rmSync(dir, { recursive: true, force: true });
});
});

describe('getSummary: exclude settings (issue #261)', () => {
it('passes through excludes/excludeKeywords/excludeUrls/maxExcludedDepth from config', async () => {
const dir = path.resolve(__dirname, '__test_fixtures_summary_excludes__');
const archiveFilePath = path.resolve(dir, 'summary-excludes.nitpicker');
const { mkdirSync, rmSync } = await import('node:fs');
mkdirSync(dir, { recursive: true });
const archive = await Archive.create({ filePath: archiveFilePath, cwd: dir });
await archive.setConfig({
baseUrl: 'https://example.com',
roots: ['https://example.com'],
name: 'test',
version: '0.13.0',
recursive: true,
interval: 0,
image: true,
fetchExternal: false,
parallels: 1,
excludes: ['/admin/*'],
excludeKeywords: ['draft'],
excludeUrls: ['https://example.com/temp'],
maxExcludedDepth: 3,
retry: 3,
fromList: false,
disableQueries: false,
userAgent: 'test',
ignoreRobots: false,
});

const result = await getSummary(archive);
expect(result.excludes).toEqual(['/admin/*']);
expect(result.excludeKeywords).toEqual(['draft']);
expect(result.excludeUrls).toEqual(['https://example.com/temp']);
expect(result.maxExcludedDepth).toBe(3);

await archive.close();
rmSync(dir, { recursive: true, force: true });
});
});
8 changes: 6 additions & 2 deletions packages/@nitpicker/query/src/get-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ export async function getSummary(accessor: ArchiveAccessor): Promise<SummaryResu
const knex = accessor.getKnex();
await requireAliasOfIdColumn(knex);
const config = await accessor.getConfig();
const baseUrl = config.baseUrl;
const roots = config.roots;
const { baseUrl, roots, excludes, excludeKeywords, excludeUrls, maxExcludedDepth } =
config;

const consoleLogCountsPromise = countConsoleLogsByType(knex);
const failedPageIdRowsPromise = knex('content_items')
Expand Down Expand Up @@ -340,6 +340,10 @@ export async function getSummary(accessor: ArchiveAccessor): Promise<SummaryResu
return {
baseUrl,
roots,
excludes,
excludeKeywords,
excludeUrls,
maxExcludedDepth,
totalPages: totalNum,
internalPages: internalNum,
externalPages: externalNum,
Expand Down
45 changes: 44 additions & 1 deletion packages/@nitpicker/query/src/get-viewer-summary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,14 @@ describe('getViewerSummary', () => {
});
});

it('reads baseUrl/roots from the archive config, not from the read model', async () => {
it('reads baseUrl/roots/exclude settings from the archive config, not from the read model', async () => {
const result = await getViewerSummary(archive);
expect(result.baseUrl).toBe('https://example.com');
expect(result.roots).toEqual(['https://example.com']);
expect(result.excludes).toEqual([]);
expect(result.excludeKeywords).toEqual([]);
expect(result.excludeUrls).toEqual([]);
expect(result.maxExcludedDepth).toBe(0);
});
});

Expand Down Expand Up @@ -363,4 +367,43 @@ describe('getViewerSummary', () => {
]);
});
});

describe('with non-empty exclude settings (issue #261)', () => {
const workingDir = path.resolve(
__dirname,
'__test_fixtures_get_viewer_summary_excludes__',
);
const archiveFilePath = path.resolve(workingDir, 'excludes-test.nitpicker');
let archive: InstanceType<typeof Archive>;

beforeAll(async () => {
const { mkdirSync } = await import('node:fs');
mkdirSync(workingDir, { recursive: true });
archive = await Archive.create({ filePath: archiveFilePath, cwd: workingDir });
await archive.setConfig({
...BASE_CONFIG,
excludes: ['/admin/*'],
excludeKeywords: ['draft'],
excludeUrls: ['https://example.com/temp'],
maxExcludedDepth: 3,
});
await buildViewerReadModel(archive);
});

afterAll(async () => {
if (archive) {
await archive.releaseHandle();
}
const { rmSync } = await import('node:fs');
rmSync(workingDir, { recursive: true, force: true });
});

it('surfaces non-empty exclude settings from config, not hardcoded defaults', async () => {
const result = await getViewerSummary(archive);
expect(result.excludes).toEqual(['/admin/*']);
expect(result.excludeKeywords).toEqual(['draft']);
expect(result.excludeUrls).toEqual(['https://example.com/temp']);
expect(result.maxExcludedDepth).toBe(3);
});
});
});
12 changes: 9 additions & 3 deletions packages/@nitpicker/query/src/get-viewer-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import type { ArchiveAccessor } from '@nitpicker/crawler';
* `JSON.parse` calls, replacing `getSummary`'s multi-second full-table
* aggregation on archives where the read model is current.
*
* `baseUrl`/`roots` are not stored in `viewer_summary` (they come from
* `accessor.getConfig()`, independent of `pages` aggregation and already
* cheap) — this function merges them back in at read time.
* `baseUrl`/`roots`/exclude settings (`excludes`, `excludeKeywords`,
* `excludeUrls`, `maxExcludedDepth`) are not stored in `viewer_summary`
* (they come from `accessor.getConfig()`, independent of `pages`
* aggregation and already cheap) — this function merges them back in at
* read time.
*
* Callers are responsible for guarding with `isViewerReadModelCurrent`
* first, the same convention `listViewerPages` uses — this function does
Expand Down Expand Up @@ -44,6 +46,10 @@ export async function getViewerSummary(
return {
baseUrl: config.baseUrl,
roots: config.roots,
excludes: config.excludes,
excludeKeywords: config.excludeKeywords,
excludeUrls: config.excludeUrls,
maxExcludedDepth: config.maxExcludedDepth,
totalPages: Number(row.total_pages),
internalPages: Number(row.internal_pages),
externalPages: Number(row.external_pages),
Expand Down
8 changes: 8 additions & 0 deletions packages/@nitpicker/query/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,14 @@ export interface SummaryResult {
baseUrl: string;
/** All user-provided root URLs. Single-root archives report `[baseUrl]`. */
roots: string[];
/** Maximum directory depth for excluded paths. */
maxExcludedDepth: number;
/** Keywords used to exclude pages from crawling. */
excludeKeywords: string[];
/** URL patterns to exclude from crawling. */
excludes: string[];
/** URL prefixes to exclude from crawling. */
excludeUrls: string[];
/**
* Total number of HTML pages (internal + external) — the historical
* "pages" count, restricted to `contentType IS NULL OR text/html` so
Expand Down
20 changes: 16 additions & 4 deletions packages/@nitpicker/viewer/src/create-app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ describe('createApp', () => {
fetchExternal: false,
parallels: 1,
roots: ['https://example.com'],
excludes: [],
excludeKeywords: [],
excludeUrls: [],
maxExcludedDepth: 0,
excludes: ['/admin/*'],
excludeKeywords: ['draft'],
excludeUrls: ['https://example.com/temp'],
maxExcludedDepth: 3,
retry: 3,
fromList: false,
disableQueries: false,
Expand Down Expand Up @@ -180,6 +180,10 @@ describe('createApp', () => {
externalPages: number;
internalContents: number;
externalContents: number;
excludes: string[];
excludeKeywords: string[];
excludeUrls: string[];
maxExcludedDepth: number;
};
expect(body.totalPages).toBeGreaterThanOrEqual(2);
/* `internalContents`/`externalContents` must pass through the API
Expand All @@ -190,6 +194,14 @@ describe('createApp', () => {
documented in the SummaryResult JSDoc. */
expect(body.internalContents).toBeGreaterThanOrEqual(body.internalPages);
expect(body.externalContents).toBeGreaterThanOrEqual(body.externalPages);
/* End-to-end coverage for issue #261: exclude settings written via
`archive.setConfig()` (crawler package) must survive the full
config → getSummaryFastPath (query package) → HTTP JSON
(viewer package) pipeline, not just the query-layer unit tests. */
expect(body.excludes).toEqual(['/admin/*']);
expect(body.excludeKeywords).toEqual(['draft']);
expect(body.excludeUrls).toEqual(['https://example.com/temp']);
expect(body.maxExcludedDepth).toBe(3);
});

it('GET /api/pages はページ一覧を返す', async () => {
Expand Down
26 changes: 26 additions & 0 deletions packages/@nitpicker/viewer/src/precomputed-disk-cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,32 @@ describe('getOrComputeOnDisk', () => {
expect(onDisk).toBe(siblingArtefact);
});

it('regenerates the artefact when a cache hit fails the isValid shape guard', async () => {
// Simulates a cache file written by an older nitpicker build whose
// `compute()` shape has since grown new required fields — the
// content-hash cache key doesn't change on a version upgrade, so
// the stale shape must be treated like corruption, not a hit.
const cacheDir = path.join(baseDir, 'stale-shape');
const precomputedDir = path.join(cacheDir, 'precomputed');
await fs.mkdir(precomputedDir, { recursive: true });
await fs.writeFile(
path.join(precomputedDir, 'shaped.json'),
JSON.stringify({ old: true }),
);

const compute = vi.fn().mockResolvedValueOnce({ old: true, fresh: true });
const result = await getOrComputeOnDisk(
cacheDir,
'shaped',
compute,
(value: { fresh?: boolean }) => value.fresh === true,
);
expect(result).toEqual({ old: true, fresh: true });
expect(compute).toHaveBeenCalledTimes(1);
const onDisk = await fs.readFile(path.join(precomputedDir, 'shaped.json'), 'utf8');
expect(JSON.parse(onDisk)).toEqual({ old: true, fresh: true });
});

it('reconstructs round-tripped Map shapes when callers serialise via entries arrays', async () => {
// Map<K, V> is not natively JSON-serialisable; callers that cache a
// Map serialise it as [[k,v],...] entries. Verify the disk layer
Expand Down
25 changes: 19 additions & 6 deletions packages/@nitpicker/viewer/src/precomputed-disk-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,21 @@ const PRECOMPUTED_DIR_NAME = 'precomputed';
* `"isolated-clusters"`). Used as the on-disk filename.
* @param compute - Loader invoked on cache miss. Its return value is
* what gets persisted; subsequent reads return the parsed JSON.
* @param isValid - Optional shape guard run against a cache hit before
* it is returned. A cached artefact from an older nitpicker build can
* be missing fields a newer `compute()` shape requires (the archive's
* content-hash key only invalidates on archive mutation, not on a
* nitpicker version upgrade) — returning it as-is would hand callers
* an object that doesn't match `T` at runtime. When `isValid` returns
* `false` the hit is treated exactly like corrupt JSON: fall through
* to `compute()` and overwrite.
* @returns The cached or freshly-computed artefact.
*/
export async function getOrComputeOnDisk<T>(
cacheDir: string,
name: string,
compute: () => Promise<T>,
isValid?: (value: T) => boolean,
): Promise<T> {
const dir = path.join(cacheDir, PRECOMPUTED_DIR_NAME);
const file = path.join(dir, `${name}.json`);
Expand All @@ -67,16 +76,20 @@ export async function getOrComputeOnDisk<T>(
let isCorrupt = false;
try {
const raw = await fs.readFile(file, 'utf8');
return JSON.parse(raw) as T;
const parsed = JSON.parse(raw) as T;
if (isValid && !isValid(parsed)) {
throw new Error('stale cache shape');
}
return parsed;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
// Either an ENOENT we did not anticipate (handled below by
// treating as miss) or a parse error — both fall through
// to compute. We only flag corruption when the file was
// reachable but the JSON did not parse, so the post-compute
// race-check overwrites the bad data.
isCorrupt = !code; // SyntaxError has no .code
// treating as miss), a parse error, or a failed `isValid`
// check — all fall through to compute. We only flag
// corruption when the file was reachable but unusable, so
// the post-compute race-check overwrites the bad data.
isCorrupt = !code; // SyntaxError / isValid failure has no .code
}
}

Expand Down
Loading
Loading