diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd2c36..03a5efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ This project follows Keep a Changelog structure with SemVer-oriented release numbers. +## [1.4.5] - 2026-06-10 + +### Fixed + +- When a site was added manually to `watchers.yml` without any prior runtime state (no + `success_since`, no `failed_since`), the first successful check never initialized + `success_since`. All four success-branch guards only checked for domain change or + failure→success recovery, but not for first-time initialization. Added `!site.success_since` + to each guard so the timestamp is written on the first successful check regardless of + domain change or prior failure state. The HDFilmCehennemi watcher with a manually-set + `last_known_mirror` previously remained without `success_since` after a successful run; now + it is correctly initialized. + ## [1.4.4] - 2026-06-10 ### Fixed diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts index ab8a28c..cea35fb 100644 --- a/__tests__/config.test.ts +++ b/__tests__/config.test.ts @@ -248,4 +248,198 @@ sites: expect(savedContent).not.toContain('last_seen'); expect(savedContent).toContain('success_since'); }); + + // ────────────────────────────────────────────────────────────────────────────── + // 9.x saveWatchers — state transition field persistence (failed_since / success_since) + // ────────────────────────────────────────────────────────────────────────────── + + test('saveWatchers: success→failure removes success_since, adds failed_since/failed_days/potentially_dead', async () => { + const watchersContent = `sites: + Site1: + last_known_mirror: himovies.bz + success_since: "2026-05-25" +`; + const watchersPath = join(tempDir, 'watchers.yml'); + writeFileSync(watchersPath, watchersContent, 'utf-8'); + + // Load, simulate failure transition, then save + const watchers = await loadWatchers(watchersPath); + const site = watchers.sites['Site1']; + + // Simulate failure branch from index.ts: delete success_since, set failure fields + delete site.success_since; + site.failed_since = '2026-06-10 16:55'; + site.failed_days = 0; + site.potentially_dead = true; + + await saveWatchers(watchers, watchersPath); + + const savedContent = readFileSync(watchersPath, 'utf-8'); + // success_since must NOT appear after failure transition + expect(savedContent).not.toContain('success_since'); + // failure fields must appear + expect(savedContent).toContain('failed_since:'); + expect(savedContent).toContain('failed_days:'); + expect(savedContent).toContain('potentially_dead:'); + // last_known_mirror must be preserved + expect(savedContent).toContain('last_known_mirror: himovies.bz'); + + // Verify round-trip: reload and check in-memory state + const reloaded = await loadWatchers(watchersPath); + expect(reloaded.sites['Site1'].success_since).toBeUndefined(); + expect(reloaded.sites['Site1'].failed_since).toBe('2026-06-10 16:55'); + expect(reloaded.sites['Site1'].failed_days).toBe(0); + expect(reloaded.sites['Site1'].potentially_dead).toBe(true); + expect(reloaded.sites['Site1'].last_known_mirror).toBe('himovies.bz'); + }); + + test('saveWatchers: failure→success removes failed_since/failed_days/potentially_dead, adds success_since', async () => { + const watchersContent = `sites: + Site2: + last_known_mirror: example027.com + failed_since: "2026-06-10 01:48" + failed_days: 0 + potentially_dead: true +`; + const watchersPath = join(tempDir, 'watchers.yml'); + writeFileSync(watchersPath, watchersContent, 'utf-8'); + + // Load, simulate recovery (failure→success), then save + const watchers = await loadWatchers(watchersPath); + const site = watchers.sites['Site2']; + + // Simulate success recovery branch from index.ts + site.success_since = '2026-06-10 15:33'; + delete site.failed_since; + delete site.failed_days; + delete site.potentially_dead; + + await saveWatchers(watchers, watchersPath); + + const savedContent = readFileSync(watchersPath, 'utf-8'); + // success_since must appear + expect(savedContent).toContain('success_since:'); + // failure fields must NOT appear + expect(savedContent).not.toContain('failed_since'); + expect(savedContent).not.toContain('failed_days'); + expect(savedContent).not.toContain('potentially_dead'); + // last_known_mirror must be preserved + expect(savedContent).toContain('last_known_mirror: example027.com'); + + // Verify round-trip + const reloaded = await loadWatchers(watchersPath); + expect(reloaded.sites['Site2'].success_since).toBe('2026-06-10 15:33'); + expect(reloaded.sites['Site2'].failed_since).toBeUndefined(); + expect(reloaded.sites['Site2'].failed_days).toBeUndefined(); + expect(reloaded.sites['Site2'].potentially_dead).toBeUndefined(); + expect(reloaded.sites['Site2'].last_known_mirror).toBe('example027.com'); + }); + + test('saveWatchers: repeated success does NOT rewrite success_since (churn suppression)', async () => { + const watchersContent = `sites: + Site3: + last_known_mirror: example001.com + success_since: "2026-05-24 12:00" +`; + const watchersPath = join(tempDir, 'watchers.yml'); + writeFileSync(watchersPath, watchersContent, 'utf-8'); + + // Load and save WITHOUT changing success_since (simulating churn suppression) + const watchers = await loadWatchers(watchersPath); + + // Do NOT touch success_since (churn suppression) + await saveWatchers(watchers, watchersPath); + + const savedContent = readFileSync(watchersPath, 'utf-8'); + // success_since must still have the original value + expect(savedContent).toContain('success_since: "2026-05-24 12:00"'); + }); + + test('saveWatchers: repeated failure preserves original failed_since', async () => { + const watchersContent = `sites: + Site4: + last_known_mirror: example001.com + failed_since: "2026-05-20 12:00" + failed_days: 0 + potentially_dead: true +`; + const watchersPath = join(tempDir, 'watchers.yml'); + writeFileSync(watchersPath, watchersContent, 'utf-8'); + + // Load, simulate repeated failure with day-bucket suppression + const watchers = await loadWatchers(watchersPath); + const site = watchers.sites['Site4']; + + // Simulate repeated failure: failed_since NOT reassigned, failed_days preserved + // (day-bucket suppression — same-day repeated failure does not rewrite failed_days) + // No changes to failure fields + await saveWatchers(watchers, watchersPath); + + const savedContent = readFileSync(watchersPath, 'utf-8'); + // Original failed_since preserved + expect(savedContent).toContain('failed_since: "2026-05-20 12:00"'); + expect(savedContent).toContain('failed_days: 0'); + expect(savedContent).toContain('potentially_dead:'); + }); + + test('saveWatchers: first-time initialization writes success_since (no prior state)', async () => { + // Simulate HDFilmCehennemi scenario: site added manually without any timestamps + const watchersContent = `sites: + HDFilmCehennemi: + initial_domain: t.co/3D4ep5ZtK4 + last_known_mirror: hdfilmcehennemi27.org +`; + const watchersPath = join(tempDir, 'watchers.yml'); + writeFileSync(watchersPath, watchersContent, 'utf-8'); + + // Load, simulate first successful check, save + const watchers = await loadWatchers(watchersPath); + const site = watchers.sites['HDFilmCehennemi']; + + // Simulate first-time initialization: no prior state, success_since was never set + expect(site.success_since).toBeUndefined(); + site.success_since = '2026-06-10 19:17'; + + await saveWatchers(watchers, watchersPath); + + const savedContent = readFileSync(watchersPath, 'utf-8'); + expect(savedContent).toContain('success_since:'); + expect(savedContent).toContain('last_known_mirror: hdfilmcehennemi27.org'); + + // Round-trip verification + const reloaded = await loadWatchers(watchersPath); + expect(reloaded.sites['HDFilmCehennemi'].success_since).toBe('2026-06-10 19:17'); + expect(reloaded.sites['HDFilmCehennemi'].last_known_mirror).toBe('hdfilmcehennemi27.org'); + }); + + test('saveWatchers: empty failed_since is cleaned up on success recovery', async () => { + const watchersContent = `sites: + Site5: + last_known_mirror: example001.com + failed_since: "" + failed_days: 0 + potentially_dead: true + success_since: "2026-06-10 15:33" +`; + const watchersPath = join(tempDir, 'watchers.yml'); + writeFileSync(watchersPath, watchersContent, 'utf-8'); + + // Load, simulate success recovery with cleanup of empty failed_since + const watchers = await loadWatchers(watchersPath); + const site = watchers.sites['Site5']; + + // If the site was already saved with both success_since and empty + // failed_since, recovery must clean up the empty failure fields + delete site.failed_since; + delete site.failed_days; + delete site.potentially_dead; + + await saveWatchers(watchers, watchersPath); + + const savedContent = readFileSync(watchersPath, 'utf-8'); + expect(savedContent).not.toContain('failed_since'); + expect(savedContent).not.toContain('failed_days'); + expect(savedContent).not.toContain('potentially_dead'); + expect(savedContent).toContain('success_since:'); + }); }); diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 7a87a96..0b6ef1d 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -840,3 +840,180 @@ describe('11.10 gitSkipReason', () => { expect(skipReason === null).toBe(true); }); }); + +// ============================================================================ +// 11.11 success_since first-time initialization (no prior state) +// ============================================================================ +describe('11.11 success_since first-time initialization (no prior state)', () => { + test('shouldUpdate branch: same LKM, no prior state → writes success_since', () => { + jest.useFakeTimers({ now: new Date('2026-06-10T19:17:00Z') }); + try { + const site: { + last_known_mirror: string; + success_since?: string; + failed_since?: string; + failed_days?: number; + potentially_dead?: boolean; + } = { + last_known_mirror: 'hdfilmcehennemi27.org', + }; + // No failed_since, no success_since → hadFailureBeforeThisRun = false + + const oldLastKnownMirror = site.last_known_mirror; // hdfilmcehennemi27.org + const effectiveNewHost = 'hdfilmcehennemi27.org'; // same domain + const hadFailureBeforeThisRun = false; + const nowFormatted = '2026-06-10 19:17'; + + // Simulate shouldUpdate success branch with FIX: + // effectiveNewHost !== oldLastKnownMirror → false + // hadFailureBeforeThisRun → false + // !site.success_since → true (first-time init) + if (effectiveNewHost !== oldLastKnownMirror || hadFailureBeforeThisRun || !site.success_since) { + if (site.success_since !== nowFormatted) { + site.success_since = nowFormatted; + } + } + delete site.failed_days; + delete site.failed_since; + delete site.potentially_dead; + + expect(site.success_since).toBe('2026-06-10 19:17'); + expect(site.last_known_mirror).toBe('hdfilmcehennemi27.org'); + expect(site.failed_since).toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + test('unchanged branch: no prior state → writes success_since', () => { + jest.useFakeTimers({ now: new Date('2026-06-10T19:17:00Z') }); + try { + const site: { + success_since?: string; + failed_since?: string; + failed_days?: number; + potentially_dead?: boolean; + } = {}; + // No state at all + + const hadFailureBeforeThisRun = false; + const nowFormatted = '2026-06-10 19:17'; + + // Simulate unchanged branch with FIX: + // hadFailureBeforeThisRun → false + // !site.success_since → true (first-time init) + if (hadFailureBeforeThisRun || !site.success_since) { + if (site.success_since !== nowFormatted) { + site.success_since = nowFormatted; + } + } + delete site.failed_days; + delete site.failed_since; + delete site.potentially_dead; + + expect(site.success_since).toBe('2026-06-10 19:17'); + expect(site.failed_since).toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + test('antibot branch: no prior state → writes success_since', () => { + jest.useFakeTimers({ now: new Date('2026-06-10T19:17:00Z') }); + try { + const site: { + last_known_mirror: string; + success_since?: string; + failed_since?: string; + failed_days?: number; + potentially_dead?: boolean; + } = { + last_known_mirror: 'example001.com', + }; + + const antibotActuallyChanged = false; // same domain + const hadFailureBeforeThisRun = false; + const nowFormatted = '2026-06-10 19:17'; + + // Simulate antibot branch with FIX + if (antibotActuallyChanged || hadFailureBeforeThisRun || !site.success_since) { + if (site.success_since !== nowFormatted) { + site.success_since = nowFormatted; + } + } + delete site.failed_days; + delete site.failed_since; + delete site.potentially_dead; + + expect(site.success_since).toBe('2026-06-10 19:17'); + expect(site.last_known_mirror).toBe('example001.com'); + } finally { + jest.useRealTimers(); + } + }); + + test('heuristic non-pattern branch: no prior state → writes success_since', () => { + jest.useFakeTimers({ now: new Date('2026-06-10T19:17:00Z') }); + try { + const site: { + non_pattern_mirror?: string; + success_since?: string; + failed_since?: string; + failed_days?: number; + potentially_dead?: boolean; + } = {}; + // No state, not even non_pattern_mirror + + const oldNonPatternMirror = undefined; + const nonPatternCanonical = 'nopattern.com'; + const hadFailureBeforeThisRun = false; + const nowFormatted = '2026-06-10 19:17'; + + // Simulate heuristic non-pattern branch with FIX + site.non_pattern_mirror = nonPatternCanonical; + if (oldNonPatternMirror !== nonPatternCanonical || hadFailureBeforeThisRun || !site.success_since) { + if (site.success_since !== nowFormatted) { + site.success_since = nowFormatted; + } + } + delete site.failed_days; + delete site.failed_since; + delete site.potentially_dead; + + expect(site.success_since).toBe('2026-06-10 19:17'); + expect(site.non_pattern_mirror).toBe('nopattern.com'); + } finally { + jest.useRealTimers(); + } + }); + + test('shouldUpdate branch: already has success_since → does NOT rewrite (churn suppression)', () => { + jest.useFakeTimers({ now: new Date('2026-06-10T19:17:00Z') }); + try { + const site: { + last_known_mirror: string; + success_since?: string; + } = { + last_known_mirror: 'hdfilmcehennemi27.org', + success_since: '2026-06-10 12:00', // already set by previous run + }; + + const oldLastKnownMirror = site.last_known_mirror; + const effectiveNewHost = 'hdfilmcehennemi27.org'; // same domain + const hadFailureBeforeThisRun = false; + const nowFormatted = '2026-06-10 19:17'; + + // Guard: first two are false, !site.success_since is false (already has it) + // So updateSuccessSince is NOT called → churn suppressed + if (effectiveNewHost !== oldLastKnownMirror || hadFailureBeforeThisRun || !site.success_since) { + if (site.success_since !== nowFormatted) { + site.success_since = nowFormatted; + } + } + + expect(site.success_since).toBe('2026-06-10 12:00'); // unchanged + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/dist/index.js b/dist/index.js index f1d872d..a5cfd23 100644 --- a/dist/index.js +++ b/dist/index.js @@ -18679,7 +18679,7 @@ function gitSkipReason(isTestMode, dryRun, hasRealChanges) { return null; } // Version -const VERSION = "1.4.4"; +const VERSION = "1.4.5"; /** * From newHost + additionalWorkingDomains, pick the first domain after natural sorting. * This ensures consistent, deterministic selection (lowest-numbered pattern domain first). @@ -18979,9 +18979,10 @@ async function main() { if (site.non_pattern_mirror !== nonPatternCanonical) { site.non_pattern_mirror = nonPatternCanonical; } - // Update success_since only on real state transition: entry into non-pattern phase, - // change of the active non-pattern mirror, or failure → success recovery. - if (oldNonPatternMirror !== nonPatternCanonical || hadFailureBeforeThisRun.get(result.siteName)) { + // Update success_since on real state transition: entry into non-pattern phase, + // change of the active non-pattern mirror, failure → success recovery, + // or first-time initialization (no prior state). + if (oldNonPatternMirror !== nonPatternCanonical || hadFailureBeforeThisRun.get(result.siteName) || !site.success_since) { updateSuccessSince(site, nowFormatted); } delete site.failed_days; @@ -19025,8 +19026,9 @@ async function main() { summary.unchanged++; } // Single cleanup block — runs for both antibotActuallyChanged and unchanged. - // updateSuccessSince only on host change or failure→success transition (spec §8.4). - if (antibotActuallyChanged || hadFailureBeforeThisRun.get(result.siteName)) { + // updateSuccessSince on host change, failure→success transition, or + // first-time initialization (no prior state). + if (antibotActuallyChanged || hadFailureBeforeThisRun.get(result.siteName) || !site.success_since) { updateSuccessSince(site, nowFormatted); } delete site.failed_days; @@ -19059,14 +19061,15 @@ async function main() { site.last_known_mirror = effectiveNewHost; // State transition: update success_since ONLY when the effective new host is // genuinely different from the previously stored mirror, or when the site is - // recovering from a failure state. This suppresses churn in force_search_ahead - // scenarios where hostChanged=true comes from a redirect alias (Phase 1) but + // recovering from a failure state, or on first-time initialization (no prior + // success_since). This suppresses churn in force_search_ahead scenarios where + // hostChanged=true comes from a redirect alias (Phase 1) but // selectFirstByOrder picks back the same last_known_mirror — e.g., // last_known=example001.com (alias→003), collected [001, 002, 003], // effectiveNewHost = min = 001 (unchanged). Without this guard, repeated // identical runs would rewrite the timestamp and produce spurious diffs in // watchers.yml on every invocation. - if (effectiveNewHost !== oldLastKnownMirror || hadFailureBeforeThisRun.get(result.siteName)) { + if (effectiveNewHost !== oldLastKnownMirror || hadFailureBeforeThisRun.get(result.siteName) || !site.success_since) { updateSuccessSince(site, nowFormatted); } delete site.failed_days; // Reset on success @@ -19104,11 +19107,12 @@ async function main() { } } else { - // Success but no change - reset failure flags; update success_since ONLY on - // failure→success transition. Otherwise suppress churn so repeated identical - // runs do not rewrite watchers.yml with a new timestamp. + // Success but no change - reset failure flags; update success_since on + // failure→success transition or first-time initialization (no prior state). + // Suppress churn so repeated identical runs do not rewrite watchers.yml + // with a new timestamp. summary.unchanged++; - if (hadFailureBeforeThisRun.get(result.siteName)) { + if (hadFailureBeforeThisRun.get(result.siteName) || !site.success_since) { updateSuccessSince(site, nowFormatted); } delete site.failed_days; diff --git a/package-lock.json b/package-lock.json index e6d8765..152c773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rotating-domains-checker", - "version": "1.4.4", + "version": "1.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rotating-domains-checker", - "version": "1.4.4", + "version": "1.4.5", "license": "MIT", "dependencies": { "table": "^6.8.1", diff --git a/package.json b/package.json index 9b197da..e3463ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rotating-domains-checker", - "version": "1.4.4", + "version": "1.4.5", "description": "GitHub Action for checking and updating rotating domains in ad blocking filters", "main": "dist/index.js", "type": "module", diff --git a/src/index.ts b/src/index.ts index c39b3c6..580a10c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,7 @@ export function gitSkipReason( } // Version -const VERSION = "1.4.4"; +const VERSION = "1.4.5"; /** * From newHost + additionalWorkingDomains, pick the first domain after natural sorting. @@ -383,9 +383,10 @@ export async function main() { site.non_pattern_mirror = nonPatternCanonical; } - // Update success_since only on real state transition: entry into non-pattern phase, - // change of the active non-pattern mirror, or failure → success recovery. - if (oldNonPatternMirror !== nonPatternCanonical || hadFailureBeforeThisRun.get(result.siteName)) { + // Update success_since on real state transition: entry into non-pattern phase, + // change of the active non-pattern mirror, failure → success recovery, + // or first-time initialization (no prior state). + if (oldNonPatternMirror !== nonPatternCanonical || hadFailureBeforeThisRun.get(result.siteName) || !site.success_since) { updateSuccessSince(site, nowFormatted); } delete site.failed_days; @@ -448,8 +449,9 @@ export async function main() { } // Single cleanup block — runs for both antibotActuallyChanged and unchanged. - // updateSuccessSince only on host change or failure→success transition (spec §8.4). - if (antibotActuallyChanged || hadFailureBeforeThisRun.get(result.siteName)) { + // updateSuccessSince on host change, failure→success transition, or + // first-time initialization (no prior state). + if (antibotActuallyChanged || hadFailureBeforeThisRun.get(result.siteName) || !site.success_since) { updateSuccessSince(site, nowFormatted); } delete site.failed_days; @@ -493,14 +495,15 @@ export async function main() { site.last_known_mirror = effectiveNewHost; // State transition: update success_since ONLY when the effective new host is // genuinely different from the previously stored mirror, or when the site is - // recovering from a failure state. This suppresses churn in force_search_ahead - // scenarios where hostChanged=true comes from a redirect alias (Phase 1) but + // recovering from a failure state, or on first-time initialization (no prior + // success_since). This suppresses churn in force_search_ahead scenarios where + // hostChanged=true comes from a redirect alias (Phase 1) but // selectFirstByOrder picks back the same last_known_mirror — e.g., // last_known=example001.com (alias→003), collected [001, 002, 003], // effectiveNewHost = min = 001 (unchanged). Without this guard, repeated // identical runs would rewrite the timestamp and produce spurious diffs in // watchers.yml on every invocation. - if (effectiveNewHost !== oldLastKnownMirror || hadFailureBeforeThisRun.get(result.siteName)) { + if (effectiveNewHost !== oldLastKnownMirror || hadFailureBeforeThisRun.get(result.siteName) || !site.success_since) { updateSuccessSince(site, nowFormatted); } delete site.failed_days; // Reset on success @@ -539,11 +542,12 @@ export async function main() { } } else { - // Success but no change - reset failure flags; update success_since ONLY on - // failure→success transition. Otherwise suppress churn so repeated identical - // runs do not rewrite watchers.yml with a new timestamp. + // Success but no change - reset failure flags; update success_since on + // failure→success transition or first-time initialization (no prior state). + // Suppress churn so repeated identical runs do not rewrite watchers.yml + // with a new timestamp. summary.unchanged++; - if (hadFailureBeforeThisRun.get(result.siteName)) { + if (hadFailureBeforeThisRun.get(result.siteName) || !site.success_since) { updateSuccessSince(site, nowFormatted); } delete site.failed_days;