diff --git a/LICENSE b/LICENSE index 8c6c1b2..af430fe 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025-2026 SkillBridge Contributors +Copyright (c) 2025-2026 Heznpc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4daf8b8..28686f8 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ See our full [Privacy Policy](PRIVACY_POLICY.md). | AI Tutor | Claude Sonnet 4.6 via Puter.js | | Curated Dictionaries | Hand-tuned JSON (1,100+ × 12 languages) | | Translation Cache | IndexedDB | -| CJK Font Rendering | Google Fonts Noto Sans | +| CJK Font Rendering | Local system/Noto fallback stacks | > **Built with [Claude Code](https://docs.anthropic.com/en/docs/claude-code).** > This project — from architecture design and feature implementation to debugging and the demo GIF — was developed using Claude Code as an AI pair-programming partner. diff --git a/TODO.md b/TODO.md index acbf7da..98e728d 100644 --- a/TODO.md +++ b/TODO.md @@ -81,10 +81,10 @@ be treated as the real public service, not just a repo-ready release candidate. removed before relying on the CD upload path. Before live publish, set `CWS_DASHBOARD_READY_VERSION` to the manifest version only after the CWS dashboard listing/media/privacy fields are refreshed. -- [ ] **Migrate CWS CD off the v1.1 upload action.** The current pinned upload - action still uses the older Chrome Web Store upload/publish API surface. Keep - the new dashboard-ready and target-listing guards, but replace the action with - the current CWS API path before the older endpoint support window closes. +- [x] **CWS CD upload action on the current pinned path.** The workflow uses + `mnao305/chrome-extension-upload` v6.0.0 with the dashboard-ready and + target-listing guards still in place. Re-check this only when the action or + Chrome Web Store API announces a new migration window. ### P2 — service quality after the store build is live diff --git a/manifest.json b/manifest.json index 2411488..ea903db 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "description": "__MSG_extDescription__", "version": "3.5.41", "minimum_chrome_version": "124", - "author": "SkillBridge Contributors", + "author": "Heznpc", "homepage_url": "https://github.com/heznpc/skillbridge", "default_locale": "en", "permissions": ["storage", "alarms"], diff --git a/package.json b/package.json index 159c7da..6689ea4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "testPathIgnorePatterns": [ "/node_modules/", "/dist/", + "/.claude/worktrees/", "/tests/e2e/" ], "modulePathIgnorePatterns": [ diff --git a/scripts/build-bundle.js b/scripts/build-bundle.js index 82700c8..9436199 100644 --- a/scripts/build-bundle.js +++ b/scripts/build-bundle.js @@ -63,6 +63,8 @@ async function build() { copyDir(path.join(ROOT, 'assets', 'icons'), path.join(DIST, 'assets', 'icons')); copyDir(path.join(ROOT, '_locales'), path.join(DIST, '_locales')); copyDir(path.join(ROOT, 'src/data'), path.join(DIST, 'src/data')); + fs.copyFileSync(path.join(ROOT, 'LICENSE'), path.join(DIST, 'LICENSE')); + fs.copyFileSync(path.join(ROOT, 'THIRD_PARTY_NOTICES.md'), path.join(DIST, 'THIRD_PARTY_NOTICES.md')); // Copy other web-accessible resources fs.mkdirSync(path.join(DIST, 'src/lib'), { recursive: true }); diff --git a/scripts/build-firefox.js b/scripts/build-firefox.js index 562c99d..b79ec1e 100644 --- a/scripts/build-firefox.js +++ b/scripts/build-firefox.js @@ -85,6 +85,8 @@ for (const dir of ['_locales', 'src']) { copyDir(path.join(ROOT, dir), path.join(DIST_DIR, dir)); } copyDir(path.join(ROOT, 'assets', 'icons'), path.join(DIST_DIR, 'assets', 'icons')); +fs.copyFileSync(path.join(ROOT, 'LICENSE'), path.join(DIST_DIR, 'LICENSE')); +fs.copyFileSync(path.join(ROOT, 'THIRD_PARTY_NOTICES.md'), path.join(DIST_DIR, 'THIRD_PARTY_NOTICES.md')); // Write the Firefox-specific manifest fs.writeFileSync(path.join(DIST_DIR, 'manifest.json'), JSON.stringify(firefoxManifest, null, 2) + '\n'); diff --git a/scripts/check-academy-courses.js b/scripts/check-academy-courses.js index 1c0dfe8..2ce2288 100644 --- a/scripts/check-academy-courses.js +++ b/scripts/check-academy-courses.js @@ -182,7 +182,7 @@ async function main() { ...liveSlugs.map((s) => `- ${knownSlugs.has(s) ? '✅' : '🆕'} \`${s}\``), '', '### Required follow-up (48h terminology SLA)\n', - '1. Add a section for each new course to all 10 premium-language dictionaries in `src/data/`.', + '1. Add a section for each new course to all 12 premium-language dictionaries in `src/data/`.', '2. Map the slug(s) into `FLASHCARD_COURSE_MAP` in `src/lib/constants.js`.', '3. `npm run check:dict-coverage` must pass before the issue closes.', ].join('\n'); diff --git a/scripts/check-dicts.js b/scripts/check-dicts.js index 90ab2cc..143c996 100644 --- a/scripts/check-dicts.js +++ b/scripts/check-dicts.js @@ -7,15 +7,49 @@ * node scripts/check-dicts.js * * In CI (CI=true), writes a report to dict-check-report.txt and exits - * non-zero if any dictionary is stale. + * non-zero if any review-complete dictionary is stale. Dictionaries still + * marked nativeReview=recruiting are explicit review debt, not a fake + * "reviewed" state, so they warn without blocking unrelated dependency PRs. */ const fs = require('fs'); const path = require('path'); -const DATA_DIR = path.join(__dirname, '..', 'src', 'data'); +const DATA_DIR = process.env.SB_DICT_FRESHNESS_DIR || path.join(__dirname, '..', 'src', 'data'); const STALE_THRESHOLD_DAYS = 90; +function writeCiReport(results) { + if (!process.env.CI) return; + + const stale = results.filter((r) => r.status === 'STALE'); + const reviewNeeded = results.filter((r) => r.status === 'REVIEW_NEEDED'); + const report = [ + '### Blocking stale dictionaries\n', + ...(stale.length + ? stale.map( + (r) => + `- **${r.file}** (\`${r.lang}\`): last updated ${r.lastUpdated} (${r.daysSince} days ago), nativeReview=${r.nativeReview} — STALE`, + ) + : ['- None']), + '', + '### Native review recruiting\n', + ...(reviewNeeded.length + ? reviewNeeded.map( + (r) => + `- **${r.file}** (\`${r.lang}\`): last updated ${r.lastUpdated} (${r.daysSince} days ago), nativeReview=${r.nativeReview} — REVIEW_NEEDED`, + ) + : ['- None']), + '', + '### All dictionaries\n', + '| File | Language | Last Updated | Days Ago | Native Review | Status |', + '|------|----------|-------------|----------|---------------|--------|', + ...results + .filter((r) => r.lastUpdated) + .map((r) => `| ${r.file} | ${r.lang} | ${r.lastUpdated} | ${r.daysSince} | ${r.nativeReview} | ${r.status} |`), + ].join('\n'); + fs.writeFileSync('dict-check-report.txt', report); +} + function main() { console.log('Dictionary Freshness Check'); console.log(`Data dir: ${DATA_DIR}`); @@ -36,7 +70,7 @@ function main() { const now = new Date(); const results = []; - let hasStale = false; + let hasBlockingStale = false; for (const file of files) { const filePath = path.join(DATA_DIR, file); @@ -65,43 +99,44 @@ function main() { const daysSince = Math.floor((now - updatedDate) / (1000 * 60 * 60 * 24)); const lang = data._meta.lang || file.replace('.json', ''); + const nativeReview = data._meta.nativeReview || 'unknown'; + const reviewRecruiting = nativeReview === 'recruiting'; if (daysSince > STALE_THRESHOLD_DAYS) { - console.log(` [STALE] ${file} (${lang}): last updated ${lastUpdated} — ${daysSince} days ago`); - results.push({ file, lang, status: 'STALE', lastUpdated, daysSince }); - hasStale = true; + const status = reviewRecruiting ? 'REVIEW_NEEDED' : 'STALE'; + const label = reviewRecruiting ? 'REVIEW' : 'STALE'; + console.log( + ` [${label}] ${file} (${lang}): last updated ${lastUpdated} — ${daysSince} days ago; nativeReview=${nativeReview}`, + ); + results.push({ file, lang, status, lastUpdated, daysSince, nativeReview }); + if (!reviewRecruiting) hasBlockingStale = true; } else { - console.log(` [OK] ${file} (${lang}): last updated ${lastUpdated} — ${daysSince} days ago`); - results.push({ file, lang, status: 'OK', lastUpdated, daysSince }); + console.log( + ` [OK] ${file} (${lang}): last updated ${lastUpdated} — ${daysSince} days ago; nativeReview=${nativeReview}`, + ); + results.push({ file, lang, status: 'OK', lastUpdated, daysSince, nativeReview }); } } console.log(''); - if (!hasStale) { - console.log('All dictionaries are fresh.'); + const reviewNeeded = results.filter((r) => r.status === 'REVIEW_NEEDED'); + if (reviewNeeded.length > 0) { + console.log( + `${reviewNeeded.length} dictionary/dictionaries need native review refresh but are marked nativeReview=recruiting.`, + ); + } + + if (!hasBlockingStale) { + writeCiReport(results); + if (reviewNeeded.length === 0) console.log('All dictionaries are fresh.'); + else console.log('No review-complete dictionaries are stale.'); return; } const stale = results.filter((r) => r.status === 'STALE'); - console.log(`${stale.length} dictionary/dictionaries are stale (>${STALE_THRESHOLD_DAYS} days old).`); - - if (process.env.CI) { - const report = [ - '### Stale dictionaries\n', - ...stale.map( - (r) => `- **${r.file}** (\`${r.lang}\`): last updated ${r.lastUpdated} (${r.daysSince} days ago) — STALE`, - ), - '', - '### All dictionaries\n', - '| File | Language | Last Updated | Days Ago | Status |', - '|------|----------|-------------|----------|--------|', - ...results - .filter((r) => r.lastUpdated) - .map((r) => `| ${r.file} | ${r.lang} | ${r.lastUpdated} | ${r.daysSince} | ${r.status} |`), - ].join('\n'); - fs.writeFileSync('dict-check-report.txt', report); - } + console.log(`${stale.length} review-complete dictionary/dictionaries are stale (>${STALE_THRESHOLD_DAYS} days old).`); + writeCiReport(results); process.exit(1); } diff --git a/src/content/content.css b/src/content/content.css index 377a59d..12b3cc8 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -8,8 +8,8 @@ Open Sans (UI) — all Latin-only fonts. CJK and other scripts need proper fallbacks to prevent rendering issues (tofu/boxes). - Strategy: Inject Google Fonts Noto Sans variants per language, - and use :lang() selectors to apply proper font stacks. + Strategy: apply local system/Noto fallback stacks through scoped + si18n-lang-* classes. No remote font fetches are performed. ============================================================ */ /* ============================================================ @@ -27,8 +27,8 @@ looks bolder than Copernicus serif at 700). ============================================================ */ -/* NOTE: @import removed — fonts loaded via tag in content.js - to avoid Chrome extension CSP blocking of @import in content scripts */ +/* NOTE: @import and runtime font injection are intentionally absent. + Extension pages rely on the user's installed fonts and platform fallbacks. */ /* ---- Korean ---- */ diff --git a/src/content/content.js b/src/content/content.js index 3ec57fb..87df80d 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -758,36 +758,6 @@ } } - // Language → Google Fonts family (load only what's needed) - const _LANG_FONT_MAP = { - ko: 'Noto+Sans+KR', - ja: 'Noto+Sans+JP', - 'zh-CN': 'Noto+Sans+SC', - 'zh-TW': 'Noto+Sans+TC', - ar: 'Noto+Sans+Arabic', - hi: 'Noto+Sans+Devanagari', - bn: 'Noto+Sans+Bengali', - th: 'Noto+Sans+Thai', - he: 'Noto+Sans+Hebrew', - }; - - function injectGoogleFonts(lang) { - if (document.getElementById('sb-google-fonts')) return; - const family = _LANG_FONT_MAP[lang]; - if (!family) return; // Latin-script languages use system fonts - - const link = document.createElement('link'); - link.id = 'sb-google-fonts'; - link.rel = 'stylesheet'; - link.href = `https://fonts.googleapis.com/css2?family=${family}:wght@400;500;700&display=swap`; - link.onerror = () => { - // Graceful fallback — system fonts will be used via CSS font-family stack - console.debug('[SkillBridge] Google Fonts unavailable, using system fonts'); - link.remove(); - }; - document.head.appendChild(link); - } - function updateLangClass(lang) { // html lang (screen readers) + dir (RTL) are page-level semantics — set on // the document regardless of scope. @@ -804,7 +774,6 @@ for (const cls of [...el.classList].filter((c) => c.startsWith('si18n-lang-'))) el.classList.remove(cls); if (lang && lang !== 'en') el.classList.add(`si18n-lang-${lang}`); } - if (lang && lang !== 'en') injectGoogleFonts(lang); } function safeReplaceText(el, newText) { diff --git a/src/content/gt-queue.js b/src/content/gt-queue.js index 8f0f858..ef85575 100644 --- a/src/content/gt-queue.js +++ b/src/content/gt-queue.js @@ -379,11 +379,12 @@ if (gtGeneration !== myGeneration) return; const uncached = []; + const useGeminiBlocks = sb.hostCaps?.bridge !== false; for (let i = 0; i < batch.length; i++) { if (cacheResults[i]) { const item = batch[i]; if (item.el && item.el.parentNode) { - if (item.needsGemini) { + if (item.needsGemini && useGeminiBlocks) { uncached.push(item); } else { const translated = window._protectedTerms.restoreProtectedTerms(cacheResults[i]); @@ -396,8 +397,8 @@ } } - const gtItems = uncached.filter((item) => !item.needsGemini); - const geminiItems = uncached.filter((item) => item.needsGemini); + const gtItems = uncached.filter((item) => !item.needsGemini || !useGeminiBlocks); + const geminiItems = useGeminiBlocks ? uncached.filter((item) => item.needsGemini) : []; for (const item of geminiItems) { if (item.el && item.el.parentNode) { diff --git a/src/lib/constants.js b/src/lib/constants.js index 6eeaa3e..0167788 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -1631,6 +1631,11 @@ const FLASHCARD_COURSE_MAP = { 'claude-with-google-vertex': ['cloudDeployment'], // Claude Developer Platform 'claude-platform-101': ['claudePlatform'], + // Certification / FAQ pages are public catalog entries. They do not have a + // dedicated terminology deck yet, so route them to the closest Claude platform + // deck instead of failing the live catalog drift gate. + 'certification-faq': ['claudePlatform'], + 'claude-certified-architect-foundations-certification': ['claudePlatform'], // AI Fluency courses 'ai-fluency': ['aiFluency'], 'ai-fluency-framework': ['aiFluency'], diff --git a/src/lib/protected-terms.js b/src/lib/protected-terms.js index f9497d4..ac4c474 100644 --- a/src/lib/protected-terms.js +++ b/src/lib/protected-terms.js @@ -15,6 +15,13 @@ let _protectedKeepEnglish = ''; let _selfDupRe = null; + const CORE_PROTECTED_TERMS = Object.freeze({ + Claude: [], + Anthropic: [], + API: [], + SDK: [], + }); + /** * Build the map of wrong->correct term replacements for the given language. * No-ops if the map is already built for the same language. @@ -27,7 +34,7 @@ _protectedTermsLang = targetLang; const map = {}; - const protectedEntries = translator.getProtectedTerms?.() || {}; + const protectedEntries = { ...CORE_PROTECTED_TERMS, ...(translator.getProtectedTerms?.() || {}) }; for (const [correct, wrongForms] of Object.entries(protectedEntries)) { if (!Array.isArray(wrongForms)) continue; for (const wrong of wrongForms) { diff --git a/src/popup/popup.html b/src/popup/popup.html index 5569d4b..d1a7567 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -208,8 +208,8 @@

SkillBridge

- Navigate to anthropic.skilljar.com to start - learning. + Open an Anthropic Skilljar course or a + Claude tutorial to start learning.

diff --git a/src/popup/popup.js b/src/popup/popup.js index bf6db63..2da1b38 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -19,18 +19,31 @@ function isSkilljarHost(url) { } } +function isClaudeTutorialUrl(url) { + try { + const parsed = new URL(url); + return parsed.hostname === 'claude.com' && parsed.pathname.startsWith('/resources/tutorials/'); + } catch { + return false; + } +} + +function isSupportedPage(url) { + return isSkilljarHost(url) || isClaudeTutorialUrl(url); +} + document.addEventListener('DOMContentLoaded', async () => { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - const isSkilljar = isSkilljarHost(tab?.url); + const isSupported = isSupportedPage(tab?.url); - document.getElementById('main-content').style.display = isSkilljar ? 'block' : 'none'; - document.getElementById('not-skilljar').style.display = isSkilljar ? 'none' : 'block'; + document.getElementById('main-content').style.display = isSupported ? 'block' : 'none'; + document.getElementById('not-skilljar').style.display = isSupported ? 'none' : 'block'; // Footer from model constants document.getElementById('footer').innerHTML = `Google Translate + ${SKILLBRIDGE_MODEL_LABELS.GEMINI}
AI Tutor: ${SKILLBRIDGE_MODEL_LABELS.CLAUDE}`; - if (!isSkilljar) return; + if (!isSupported) return; const stored = await chrome.storage.local.get(['targetLanguage', 'autoTranslate']); let lang = stored.targetLanguage || 'en'; diff --git a/store-assets/01-translate.png b/store-assets/01-translate.png index b5f7290..1f68bc6 100644 Binary files a/store-assets/01-translate.png and b/store-assets/01-translate.png differ diff --git a/store-assets/02-language-select.png b/store-assets/02-language-select.png index 36237e0..abf9a83 100644 Binary files a/store-assets/02-language-select.png and b/store-assets/02-language-select.png differ diff --git a/store-assets/03-sidebar-tutor.png b/store-assets/03-sidebar-tutor.png index c06dc86..0118ba8 100644 Binary files a/store-assets/03-sidebar-tutor.png and b/store-assets/03-sidebar-tutor.png differ diff --git a/store-assets/04-flashcards.png b/store-assets/04-flashcards.png index be9c8e8..1430956 100644 Binary files a/store-assets/04-flashcards.png and b/store-assets/04-flashcards.png differ diff --git a/store-assets/05-exam-safe.png b/store-assets/05-exam-safe.png index 771d29b..05f3756 100644 Binary files a/store-assets/05-exam-safe.png and b/store-assets/05-exam-safe.png differ diff --git a/store-assets/RELEASE_CHECKLIST.md b/store-assets/RELEASE_CHECKLIST.md index febd1b4..2ba16b6 100644 --- a/store-assets/RELEASE_CHECKLIST.md +++ b/store-assets/RELEASE_CHECKLIST.md @@ -1,10 +1,10 @@ # Release Checklist — v3.5.41 re-publication -> Refreshed 2026-06-23 for v3.5.41. Since the last dashboard-ready checklist: +> Refreshed 2026-07-06 for v3.5.41. Since the last dashboard-ready checklist: > Indonesian became a Premium language, the live Academy catalog check reports -> 20 published courses wired into the 33-slug coverage map, and protected-term -> restoration now also covers cached GT translations. This is the source of -> truth for the next dashboard upload. +> 22 published catalog slugs wired into the 35-slug coverage map, and +> protected-term restoration now also covers cached GT translations. This is the +> source of truth for the next dashboard upload. CWS listing status: - Published: **v1.0.1** (uploaded 2026-03-10) @@ -14,7 +14,8 @@ CWS listing status: The remaining publish steps cross trust boundaries the automation can't cross (your hands on the dashboard, the icon design decision, the public-variable toggle). -Everything code-side is ready; regenerate the upload artifact immediately before +Do not treat this checklist as code-side green until `npm run release:verify` +passes in the release checkout. Regenerate the upload artifact immediately before dashboard upload. ## What's already prepared (no further action needed) @@ -35,9 +36,10 @@ dashboard upload. connect to the privacy policy link". `github.com` links are case-insensitive, so the homepage/support URLs are fine lowercase — only the `github.io` URL must be capital-B. -- ✅ Latest local gate snapshot: rerun before dashboard upload with - `npm run validate`, `npm run check:dict-coverage`, `npm test`, and - `npm run test:e2e` +- ⚠️ Latest local gate snapshot: rerun before dashboard upload with + `npm run release:verify`. As of 2026-07-06, dictionary freshness may report + recruiting-state dictionaries as review-needed warnings; that is not a native + review stamp. - ✅ AI-content gate wired into `manifest.json:content_scripts[].js` (PR #145 hotfix) - ✅ CWS-drift watcher will keep this from drifting 3 months again - ✅ Italian dictionary live (PR #140) — timed with Anthropic Milan office opening 2026-05-27 diff --git a/tests/build-bundle.test.js b/tests/build-bundle.test.js index 4f32fc8..82fe479 100644 --- a/tests/build-bundle.test.js +++ b/tests/build-bundle.test.js @@ -32,6 +32,11 @@ describe('bundled artifact shape', () => { expect(fs.existsSync(path.join(DIST_DIR, 'assets', 'screenshots'))).toBe(false); }); + test('copies license and third-party notices into the upload artifact', () => { + expect(fs.existsSync(path.join(DIST_DIR, 'LICENSE'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'THIRD_PARTY_NOTICES.md'))).toBe(true); + }); + test('does not copy repo-only development surfaces', () => { for (const name of ['tests', 'scripts', 'coverage', 'test-results', 'package.json', 'package-lock.json']) { expect(fs.existsSync(path.join(DIST_DIR, name))).toBe(false); diff --git a/tests/build-firefox.test.js b/tests/build-firefox.test.js index 123a13b..0d0c144 100644 --- a/tests/build-firefox.test.js +++ b/tests/build-firefox.test.js @@ -158,4 +158,9 @@ describe('Firefox build file copying', () => { expect(fs.existsSync(path.join(DIST_DIR, 'assets', 'icons', 'icon128.png'))).toBe(true); expect(fs.existsSync(path.join(DIST_DIR, 'assets', 'screenshots'))).toBe(false); }); + + test('copies license and third-party notices', () => { + expect(fs.existsSync(path.join(DIST_DIR, 'LICENSE'))).toBe(true); + expect(fs.existsSync(path.join(DIST_DIR, 'THIRD_PARTY_NOTICES.md'))).toBe(true); + }); }); diff --git a/tests/dict-freshness-checker.test.js b/tests/dict-freshness-checker.test.js new file mode 100644 index 0000000..df1130b --- /dev/null +++ b/tests/dict-freshness-checker.test.js @@ -0,0 +1,74 @@ +/** + * Regression tests for scripts/check-dicts.js. + * + * nativeReview=recruiting means the dictionary is honest review debt, not a + * completed native review. A stale recruiting dictionary should warn and + * appear in the CI report, but it must not block unrelated PRs. + */ + +/* global describe, test, expect */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-dicts.js'); + +function writeDict(dir, name, meta) { + fs.writeFileSync( + path.join(dir, `${name}.json`), + JSON.stringify( + { + _meta: { + lang: name, + lastUpdated: '2026-01-01', + ...meta, + }, + }, + null, + 2, + ), + ); +} + +function runWithFixture(dir) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-dict-freshness-cwd-')); + const r = spawnSync(process.execPath, [SCRIPT], { + env: { + ...process.env, + CI: 'true', + SB_DICT_FRESHNESS_DIR: dir, + }, + cwd, + encoding: 'utf8', + }); + let report = ''; + const reportPath = path.join(cwd, 'dict-check-report.txt'); + if (fs.existsSync(reportPath)) report = fs.readFileSync(reportPath, 'utf8'); + return { code: r.status ?? 1, out: (r.stdout || '') + (r.stderr || ''), report }; +} + +describe('dictionary freshness native review policy', () => { + test('stale recruiting dictionaries warn without failing', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-dict-freshness-')); + writeDict(dir, 'ko', { nativeReview: 'recruiting' }); + + const res = runWithFixture(dir); + expect(res.code).toBe(0); + expect(res.out).toMatch(/REVIEW_NEEDED|REVIEW/); + expect(res.out).toMatch(/No review-complete dictionaries are stale/); + expect(res.report).toMatch(/REVIEW_NEEDED/); + }); + + test('stale reviewed dictionaries fail the gate', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-dict-freshness-')); + writeDict(dir, 'ko', { nativeReview: 'reviewed' }); + + const res = runWithFixture(dir); + expect(res.code).toBe(1); + expect(res.out).toMatch(/STALE/); + expect(res.out).toMatch(/review-complete dictionary/); + expect(res.report).toMatch(/Blocking stale dictionaries/); + }); +}); diff --git a/tests/gt-queue.test.js b/tests/gt-queue.test.js index ae5538c..332b542 100644 --- a/tests/gt-queue.test.js +++ b/tests/gt-queue.test.js @@ -78,3 +78,11 @@ describe('isLikelyEnglish', () => { expect(isLikelyEnglish('ab가나')).toBe(false); }); }); + +describe('inline-tag fallback routing', () => { + test('translation-only hosts send inline-tag items through GT instead of leaving them untranslated', () => { + expect(src).toContain('const useGeminiBlocks = sb.hostCaps?.bridge !== false;'); + expect(src).toContain('const gtItems = uncached.filter((item) => !item.needsGemini || !useGeminiBlocks);'); + expect(src).toContain('const geminiItems = useGeminiBlocks ? uncached.filter((item) => item.needsGemini) : [];'); + }); +}); diff --git a/tests/popup-a11y.test.js b/tests/popup-a11y.test.js index 2434326..23aab65 100644 --- a/tests/popup-a11y.test.js +++ b/tests/popup-a11y.test.js @@ -17,6 +17,7 @@ const fs = require('fs'); const path = require('path'); const POPUP_HTML = fs.readFileSync(path.join(__dirname, '..', 'src', 'popup', 'popup.html'), 'utf8'); +const POPUP_JS = fs.readFileSync(path.join(__dirname, '..', 'src', 'popup', 'popup.js'), 'utf8'); describe('popup.html accessibility', () => { /** @returns {Document} */ @@ -38,3 +39,24 @@ describe('popup.html accessibility', () => { expect(label.id).toBe('lang-label'); }); }); + +describe('popup supported page gate', () => { + const helperStart = POPUP_JS.indexOf('function isSkilljarHost'); + const helperEnd = POPUP_JS.indexOf('\n\ndocument.addEventListener', helperStart); + if (helperStart === -1 || helperEnd === -1) { + throw new Error('Could not extract popup supported-page helpers'); + } + const { isSupportedPage } = new Function(`${POPUP_JS.slice(helperStart, helperEnd)}\nreturn { isSupportedPage };`)(); + + test('supports Anthropic Skilljar course pages', () => { + expect(isSupportedPage('https://anthropic.skilljar.com/claude-101')).toBe(true); + }); + + test('supports Claude tutorial pages', () => { + expect(isSupportedPage('https://claude.com/resources/tutorials/build-with-claude')).toBe(true); + }); + + test('rejects lookalike Skilljar hostnames', () => { + expect(isSupportedPage('https://skilljar.com.attacker.example/claude-101')).toBe(false); + }); +}); diff --git a/tests/protected-terms.test.js b/tests/protected-terms.test.js index 35b3daa..8666750 100644 --- a/tests/protected-terms.test.js +++ b/tests/protected-terms.test.js @@ -185,9 +185,13 @@ describe('Protected Terms System (real production code)', () => { expect(terms).toContain('API'); }); - test('falls back to DEFAULT_PROTECTED_TERMS when entries are empty', () => { + test('keeps core protected terms when locale entries are empty', () => { buildProtectedTermsMap('ko', fakeTranslator({})); - expect(getKeepEnglishTerms()).toBe(DEFAULT_PROTECTED_TERMS); + const terms = getKeepEnglishTerms(); + expect(terms).toContain('Claude'); + expect(terms).toContain('Anthropic'); + expect(terms).toContain('API'); + expect(terms).toContain('SDK'); }); }); }); diff --git a/tests/translation-scope.test.js b/tests/translation-scope.test.js index 7c6f4d5..442276b 100644 --- a/tests/translation-scope.test.js +++ b/tests/translation-scope.test.js @@ -98,8 +98,8 @@ const updateLangSrc = contentSrc.match(/function updateLangClass\(lang\) \{[\s\S if (!updateLangSrc) { throw new Error('Could not extract updateLangClass from content.js — did the source shape change?'); } -const updateLangFactory = new Function('sb', 'injectGoogleFonts', `${updateLangSrc[0]}\nreturn updateLangClass;`); -const makeUpdate = (translationScope) => updateLangFactory({ translationScope }, () => {}); +const updateLangFactory = new Function('sb', `${updateLangSrc[0]}\nreturn updateLangClass;`); +const makeUpdate = (translationScope) => updateLangFactory({ translationScope }); describe('updateLangClass — lang/font class targets the scope, not the whole page', () => { beforeEach(() => { @@ -123,6 +123,11 @@ describe('updateLangClass — lang/font class targets the scope, not the whole p expect(document.documentElement.lang).toBe('ja'); }); + test('does not inject remote font links', () => { + makeUpdate(null)('ko'); + expect(document.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull(); + }); + test('switching to en clears the lang class and resets lang/dir', () => { const update = makeUpdate(null); update('ar');