diff --git a/extension/src/shared/__tests__/password-coloring.test.ts b/extension/src/shared/__tests__/password-coloring.test.ts new file mode 100644 index 0000000..b7421ce --- /dev/null +++ b/extension/src/shared/__tests__/password-coloring.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring'; + +describe('colorizePassword', () => { + + function classes(frag: DocumentFragment): string[] { + return Array.from(frag.querySelectorAll('span')).map(s => s.className); + } + function texts(frag: DocumentFragment): string[] { + return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? ''); + } + + it('returns empty fragment for empty input', () => { + const frag = colorizePassword(''); + expect(frag.childNodes.length).toBe(0); + }); + + it('classifies a mixed-class run', () => { + const frag = colorizePassword('aB3$xY'); + expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]); + expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']); + }); + + it('all-letters produces a single letter span', () => { + const frag = colorizePassword('passwd'); + expect(classes(frag)).toEqual([PWD_LETTER]); + expect(texts(frag)).toEqual(['passwd']); + }); + + it('all-digits produces a single digit span', () => { + const frag = colorizePassword('123456'); + expect(classes(frag)).toEqual([PWD_DIGIT]); + expect(texts(frag)).toEqual(['123456']); + }); + + it('all-symbols produces a single symbol span', () => { + const frag = colorizePassword('!@#$%^'); + expect(classes(frag)).toEqual([PWD_SYMBOL]); + expect(texts(frag)).toEqual(['!@#$%^']); + }); + + it('classifies unicode letters as letters', () => { + const frag = colorizePassword('áñü'); + expect(classes(frag)).toEqual([PWD_LETTER]); + }); + + it('classifies whitespace as symbol', () => { + const frag = colorizePassword('a b'); + expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]); + expect(texts(frag)).toEqual(['a', ' ', 'b']); + }); + + it('representative password snapshot: aB3$xY7&_!', () => { + const frag = colorizePassword('aB3$xY7&_!'); + expect(classes(frag)).toEqual([ + PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, + ]); + expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']); + }); +}); diff --git a/extension/src/shared/password-coloring.ts b/extension/src/shared/password-coloring.ts new file mode 100644 index 0000000..222da85 --- /dev/null +++ b/extension/src/shared/password-coloring.ts @@ -0,0 +1,35 @@ +export const PWD_DIGIT = 'pwd-digit'; +export const PWD_SYMBOL = 'pwd-symbol'; +export const PWD_LETTER = 'pwd-letter'; + +type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER; + +function classify(ch: string): Class { + if (/^\d$/.test(ch)) return PWD_DIGIT; + if (/^\p{L}$/u.test(ch)) return PWD_LETTER; + return PWD_SYMBOL; +} + +export function colorizePassword(text: string): DocumentFragment { + const frag = document.createDocumentFragment(); + if (text.length === 0) return frag; + + const codepoints = Array.from(text); + let runStart = 0; + let runClass = classify(codepoints[0]); + + for (let i = 1; i <= codepoints.length; i++) { + const c = i < codepoints.length ? classify(codepoints[i]) : null; + if (c !== runClass) { + const span = document.createElement('span'); + span.className = runClass; + span.textContent = codepoints.slice(runStart, i).join(''); + frag.appendChild(span); + if (c !== null) { + runStart = i; + runClass = c; + } + } + } + return frag; +}