diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
new file mode 100644
index 0000000..8de4ff9
--- /dev/null
+++ b/.github/workflows/auto-assign.yml
@@ -0,0 +1,12 @@
+name: Auto Assign
+on:
+ issues:
+ types: [opened]
+ pull_request:
+ types: [opened]
+jobs:
+ run:
+ uses: helpers4/.github/.github/workflows/reusable-auto-assign.yml@main
+ with:
+ assignees: baxyz
+ numOfAssignee: 1
diff --git a/.github/workflows/job-build.yml b/.github/workflows/job-build.yml
index 94a480f..5f2c991 100644
--- a/.github/workflows/job-build.yml
+++ b/.github/workflows/job-build.yml
@@ -6,7 +6,7 @@ on:
node-version:
required: false
type: string
- default: "lts"
+ default: "24"
artifact-name:
required: false
type: string
diff --git a/.github/workflows/job-lint.yml b/.github/workflows/job-lint.yml
index 295af2f..ab15ebe 100644
--- a/.github/workflows/job-lint.yml
+++ b/.github/workflows/job-lint.yml
@@ -6,7 +6,7 @@ on:
node-version:
required: false
type: string
- default: "lts"
+ default: "24"
outputs:
status:
description: "Lint execution status"
diff --git a/.github/workflows/job-pr-comment.yml b/.github/workflows/job-pr-comment.yml
new file mode 100644
index 0000000..60545a8
--- /dev/null
+++ b/.github/workflows/job-pr-comment.yml
@@ -0,0 +1,138 @@
+name: Job - PR Comment
+
+on:
+ workflow_call:
+ inputs:
+ pr-number:
+ required: true
+ type: number
+ version-status:
+ required: true
+ type: string
+ build-status:
+ required: true
+ type: string
+ test-status:
+ required: true
+ type: string
+ lint-status:
+ required: true
+ type: string
+ typecheck-status:
+ required: true
+ type: string
+ commits-status:
+ required: true
+ type: string
+ verify-status:
+ required: true
+ type: string
+ coverage-lines:
+ required: true
+ type: string
+ coverage-branches:
+ required: true
+ type: string
+ coverage-functions:
+ required: true
+ type: string
+ coverage-statements:
+ required: true
+ type: string
+
+jobs:
+ comment:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Post PR comment
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const jobs = [
+ ['Version', '${{ inputs.version-status }}'],
+ ['Build', '${{ inputs.build-status }}'],
+ ['Tests', '${{ inputs.test-status }}'],
+ ['Lint', '${{ inputs.lint-status }}'],
+ ['TypeCheck', '${{ inputs.typecheck-status }}'],
+ ['Commits', '${{ inputs.commits-status }}'],
+ ['Verify', '${{ inputs.verify-status }}']
+ ]
+
+ const coverage = {
+ lines: parseFloat('${{ inputs.coverage-lines }}') || 0,
+ branches: parseFloat('${{ inputs.coverage-branches }}') || 0,
+ functions: parseFloat('${{ inputs.coverage-functions }}') || 0,
+ statements: parseFloat('${{ inputs.coverage-statements }}') || 0
+ }
+
+ const icon = (status) => {
+ const icons = { success: '✅', failure: '❌', skipped: '⏭️' }
+ return icons[status] || '⚠️'
+ }
+
+ const badge = (status) => {
+ const badges = { success: 'passing', failure: 'failing', skipped: 'skipped' }
+ return badges[status] || 'unknown'
+ }
+
+ const jobRows = jobs.map(([name, status]) =>
+ '| ' + icon(status) + ' | **' + name + '** | ' + badge(status) + ' |'
+ ).join('\n')
+
+ const covIcon = (val) => {
+ if (val >= 100) return '✅'
+ if (val >= 90) return '🟢'
+ if (val >= 80) return '🟡'
+ if (val >= 60) return '🟠'
+ return '🔴'
+ }
+
+ const covRows = [
+ '| ' + covIcon(coverage.lines) + ' | **Lines** | ' + coverage.lines + '% |',
+ '| ' + covIcon(coverage.branches) + ' | **Branches** | ' + coverage.branches + '% |',
+ '| ' + covIcon(coverage.functions) + ' | **Functions** | ' + coverage.functions + '% |',
+ '| ' + covIcon(coverage.statements) + ' | **Statements** | ' + coverage.statements + '% |'
+ ].join('\n')
+
+ const passed = jobs.filter(([_, s]) => s === 'success').length
+ const total = jobs.length
+ const allPassed = passed === total
+ const avgCov = ((coverage.lines + coverage.branches + coverage.functions + coverage.statements) / 4).toFixed(1)
+ const covOk = parseFloat(avgCov) >= 100
+
+ const banner = allPassed && covOk
+ ? '## ✅ PR Validation Passed'
+ : allPassed
+ ? '## 🟡 PR Validation Passed • Coverage ' + avgCov + '% (target: 100%)'
+ : '## ❌ PR Validation Failed'
+
+ const comment = banner + '\n\n---\n\n### Pipeline Status\n\n| | Job | Status |\n|:---:|-----|:------:|\n' + jobRows + '\n\n---\n\n### Code Coverage\n\n| | Metric | Coverage |\n|:---:|--------|:--------:|\n' + covRows + '\n\n---\n\n*Generated by @helpers4 CI*'
+
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: ${{ inputs.pr-number }},
+ })
+
+ const bot = comments.find(c =>
+ c.user.type === 'Bot' && c.body.includes('Pipeline Status')
+ )
+
+ if (bot) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: bot.id,
+ body: comment
+ })
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: ${{ inputs.pr-number }},
+ body: comment
+ })
+ }
diff --git a/.github/workflows/job-tests.yml b/.github/workflows/job-tests.yml
index c6d8c39..c078281 100644
--- a/.github/workflows/job-tests.yml
+++ b/.github/workflows/job-tests.yml
@@ -6,7 +6,7 @@ on:
node-version:
required: false
type: string
- default: "lts"
+ default: "24"
coverage:
required: false
type: boolean
@@ -76,22 +76,20 @@ jobs:
id: coverage
if: inputs.coverage == true
run: |
- node -e "
- const fs = require('fs');
- const path = 'coverage/coverage-summary.json';
- if (!fs.existsSync(path)) {
- console.log('Coverage summary not found');
- process.exit(1);
- }
- const summary = JSON.parse(fs.readFileSync(path, 'utf8'));
- const total = summary.total || {};
- const getPct = (key) => (total[key] && typeof total[key].pct === 'number') ? total[key].pct : 0;
- const lines = getPct('lines');
- const branches = getPct('branches');
- const functions = getPct('functions');
- const statements = getPct('statements');
- fs.appendFileSync(process.env.GITHUB_OUTPUT, `lines=${lines}\n`);
- fs.appendFileSync(process.env.GITHUB_OUTPUT, `branches=${branches}\n`);
- fs.appendFileSync(process.env.GITHUB_OUTPUT, `functions=${functions}\n`);
- fs.appendFileSync(process.env.GITHUB_OUTPUT, `statements=${statements}\n`);
- "
+ COVERAGE_FILE="coverage/coverage-summary.json"
+
+ if [ ! -f "$COVERAGE_FILE" ]; then
+ echo "Coverage summary not found"
+ exit 1
+ fi
+
+ TOTAL=$(jq '.total' "$COVERAGE_FILE")
+ LINES=$(echo "$TOTAL" | jq '.lines.pct // 0')
+ BRANCHES=$(echo "$TOTAL" | jq '.branches.pct // 0')
+ FUNCTIONS=$(echo "$TOTAL" | jq '.functions.pct // 0')
+ STATEMENTS=$(echo "$TOTAL" | jq '.statements.pct // 0')
+
+ echo "lines=$LINES" >> $GITHUB_OUTPUT
+ echo "branches=$BRANCHES" >> $GITHUB_OUTPUT
+ echo "functions=$FUNCTIONS" >> $GITHUB_OUTPUT
+ echo "statements=$STATEMENTS" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/job-typecheck.yml b/.github/workflows/job-typecheck.yml
index c1572b5..721d09b 100644
--- a/.github/workflows/job-typecheck.yml
+++ b/.github/workflows/job-typecheck.yml
@@ -6,7 +6,7 @@ on:
node-version:
required: false
type: string
- default: "lts"
+ default: "24"
outputs:
status:
description: "TypeCheck execution status"
diff --git a/.github/workflows/job-verification.yml b/.github/workflows/job-verification.yml
index 3c089dc..d975454 100644
--- a/.github/workflows/job-verification.yml
+++ b/.github/workflows/job-verification.yml
@@ -6,7 +6,7 @@ on:
node-version:
required: false
type: string
- default: "lts"
+ default: "24"
build-artifact:
required: true
type: string
diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
index bff2bd5..cccee4c 100644
--- a/.github/workflows/pr-validation.yml
+++ b/.github/workflows/pr-validation.yml
@@ -16,7 +16,6 @@ env:
WORKFLOW_ID: ${{ github.run_id }}
ARTIFACT_PACKAGE: package-json-${{ github.run_id }}
ARTIFACT_BUILD: build-${{ github.run_id }}
- NODE_VERSION: "lts"
jobs:
# Version calculation with artifact upload
@@ -37,7 +36,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version: "24"
- name: Enable corepack
run: corepack enable
@@ -58,39 +57,50 @@ jobs:
needs: version-calculation
uses: ./.github/workflows/job-build.yml
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version: "24"
upload-artifact: true
- artifact-name: ${{ env.ARTIFACT_BUILD }}
+ artifact-name: build-${{ github.run_id }}
# Test job (runs in parallel with build)
test:
needs: version-calculation
uses: ./.github/workflows/job-tests.yml
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version: "24"
coverage: true
lint:
uses: ./.github/workflows/job-lint.yml
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version: "24"
type-check:
uses: ./.github/workflows/job-typecheck.yml
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version: "24"
+
+ conventional-commits:
+ runs-on: ubuntu-latest
+ outputs:
+ status: ${{ steps.check.outputs.status }}
+ steps:
+ - name: Validate conventional commits
+ id: check
+ uses: helpers4/action/conventional-commits@main
+ with:
+ pr-comment: error
# Verification job (depends on build for artifacts)
verification:
needs: [build, test]
uses: ./.github/workflows/job-verification.yml
with:
- node-version: ${{ env.NODE_VERSION }}
- build-artifact: ${{ env.ARTIFACT_BUILD }}
+ node-version: "24"
+ build-artifact: build-${{ github.run_id }}
# PR comment with results
pr-comment:
- runs-on: ubuntu-latest
+ if: always()
needs:
[
version-calculation,
@@ -98,151 +108,20 @@ jobs:
test,
lint,
type-check,
+ conventional-commits,
verification,
]
- if: always()
-
- steps:
- - name: Update PR comment
- uses: actions/github-script@v7
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const createJobsTable = (jobs) => {
- const rows = Object.entries(jobs).map(([job, status]) => {
- const icon = status === 'success' ? '✅' :
- status === 'failure' ? '❌' :
- status === 'skipped' ? '⏭️' : '⚠️';
- const statusBadge = status === 'success' ? '`passing`' :
- status === 'failure' ? '`failing`' :
- status === 'skipped' ? '`skipped`' : '`unknown`';
- return `| ${icon} | **${job}** | ${statusBadge} |`;
- }).join('\n');
-
- return `| | Job | Status |\n|:---:|-----|:------:|\n${rows}`;
- };
-
- const createCoverageSection = (coverage) => {
- const target = 100;
- const getStatus = (value) => {
- const num = parseFloat(value);
- if (num >= target) return { icon: '✅', color: 'brightgreen', label: 'perfect' };
- if (num >= 90) return { icon: '🟢', color: 'green', label: 'excellent' };
- if (num >= 80) return { icon: '🟡', color: 'yellow', label: 'good' };
- if (num >= 60) return { icon: '🟠', color: 'orange', label: 'needs work' };
- return { icon: '🔴', color: 'red', label: 'critical' };
- };
-
- const createProgressBar = (value) => {
- const pct = parseFloat(value);
- const filled = Math.floor(pct / 5);
- const empty = 20 - filled;
- return '`' + '▓'.repeat(filled) + '░'.repeat(empty) + '`';
- };
-
- const avgCoverage = (Object.values(coverage).reduce((a, b) => a + parseFloat(b), 0) / 4).toFixed(1);
- const avgStatus = getStatus(avgCoverage);
-
- const rows = Object.entries(coverage).map(([metric, value]) => {
- const status = getStatus(value);
- const bar = createProgressBar(value);
- return `| ${status.icon} | **${metric}** | ${bar} | **${value}%** |`;
- }).join('\n');
-
- const header = `
-> ${avgStatus.icon} **Overall Coverage: ${avgCoverage}%** ${avgStatus.label === 'perfect' ? '— Target reached! 🎯' : `— Target: ${target}%`}
-
-| | Metric | Progress | Coverage |
-|:---:|--------|:--------:|:--------:|
-${rows}`;
-
- return header;
- };
-
- const jobs = {
- '🔢 Version': '${{ needs.version-calculation.outputs.status || 'unknown' }}',
- '🏗️ Build': '${{ needs.build.outputs.status || 'unknown' }}',
- '🧪 Tests': '${{ needs.test.outputs.status || 'unknown' }}',
- '📝 Lint': '${{ needs.lint.outputs.status || 'unknown' }}',
- '📘 TypeCheck': '${{ needs.type-check.outputs.status || 'unknown' }}',
- '🔗 Coherency': '${{ needs.verification.outputs.status || 'unknown' }}',
- };
-
- const coverage = {
- 'Lines': '${{ needs.test.outputs.coverage-lines || '0' }}',
- 'Branches': '${{ needs.test.outputs.coverage-branches || '0' }}',
- 'Functions': '${{ needs.test.outputs.coverage-functions || '0' }}',
- 'Statements': '${{ needs.test.outputs.coverage-statements || '0' }}',
- };
-
- const passedCount = Object.values(jobs).filter(s => s === 'success').length;
- const totalJobs = Object.keys(jobs).length;
- const allPassed = passedCount === totalJobs;
- const avgCoverage = (Object.values(coverage).reduce((a, b) => a + parseFloat(b), 0) / 4).toFixed(1);
- const coverageOk = parseFloat(avgCoverage) >= 100;
-
- const statusBanner = allPassed && coverageOk
- ? '## ✅ PR Validation Passed\n\n> All checks passed and coverage target reached!'
- : allPassed
- ? `## 🟡 PR Validation Passed\n\n> All checks passed • Coverage: ${avgCoverage}% (target: 100%)`
- : `## ❌ PR Validation Failed\n\n> ${passedCount}/${totalJobs} checks passed`;
-
- const comment = `${statusBanner}
-
----
-
-### 📋 Pipeline Status
-
-${createJobsTable(jobs)}
-
----
-
-### 📊 Code Coverage
-
-${createCoverageSection(coverage)}
-
----
-
-
-ℹ️ About this report
-
-- 🎯 **Coverage Target**: 100% for all metrics
-- 🔄 This comment updates automatically with each push
-- 📈 Coverage is measured using Vitest + v8
-
-
-
-🤖 Generated by **@helpers4** CI • ${new Date().toISOString().split('T')[0]}`;
-
- try {
- const { data: comments } = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- });
-
- const botComment = comments.find(comment =>
- comment.user.type === 'Bot' && comment.body.includes('PR Validation')
- );
-
- if (botComment) {
- await github.rest.issues.updateComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: botComment.id,
- body: comment
- });
- console.log('Updated existing comment');
- } else {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- body: comment
- });
- console.log('Created new comment');
- }
- } catch (error) {
- console.error('Error updating PR comment:', error);
- console.log('Comment update failed, but continuing workflow...');
- }
+ uses: ./.github/workflows/job-pr-comment.yml
+ with:
+ pr-number: ${{ github.event.pull_request.number }}
+ version-status: ${{ needs.version-calculation.outputs.status || 'unknown' }}
+ build-status: ${{ needs.build.outputs.status || 'unknown' }}
+ test-status: ${{ needs.test.outputs.status || 'unknown' }}
+ lint-status: ${{ needs.lint.outputs.status || 'unknown' }}
+ typecheck-status: ${{ needs.type-check.outputs.status || 'unknown' }}
+ commits-status: ${{ needs.conventional-commits.outputs.status || 'unknown' }}
+ verify-status: ${{ needs.verification.outputs.status || 'unknown' }}
+ coverage-lines: ${{ needs.test.outputs.coverage-lines || '0' }}
+ coverage-branches: ${{ needs.test.outputs.coverage-branches || '0' }}
+ coverage-functions: ${{ needs.test.outputs.coverage-functions || '0' }}
+ coverage-statements: ${{ needs.test.outputs.coverage-statements || '0' }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5dba710..534d34a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,7 +26,7 @@ permissions:
id-token: write
env:
- NODE_VERSION: "lts"
+ NODE_VERSION: "24"
jobs:
# Step 1: Repository guard
@@ -39,6 +39,10 @@ jobs:
echo "::error::Release workflow can only be executed on helpers4/typescript"
exit 1
fi
+ if [ "${{ github.ref }}" != "refs/heads/main" ]; then
+ echo "::error::Release workflow can only be executed on main"
+ exit 1
+ fi
echo "✅ Repository ownership verified"
# Step 2: Lint
@@ -46,21 +50,21 @@ jobs:
needs: repo-guard
uses: ./.github/workflows/job-lint.yml
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version: "24"
# Step 3: Type check
type-check:
needs: repo-guard
uses: ./.github/workflows/job-typecheck.yml
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version: "24"
# Step 4: Tests
tests:
needs: repo-guard
uses: ./.github/workflows/job-tests.yml
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version: "24"
# Step 2: Build and verify
build-and-verify:
diff --git a/helpers/array/arrayEquals.test.ts b/helpers/array/arrayEquals.test.ts
index 788cbb2..a1d3016 100644
--- a/helpers/array/arrayEquals.test.ts
+++ b/helpers/array/arrayEquals.test.ts
@@ -23,4 +23,20 @@ describe('arrayEquals', () => {
it('should return false for nested arrays if they are not identical', () => {
expect(arrayEquals([[1, 2], [3, 4]], [[1, 2], [4, 5]])).toBe(false);
});
+
+ it('should handle arrays with objects', () => {
+ expect(arrayEquals([{ a: 1 }, { b: 2 }], [{ b: 2 }, { a: 1 }])).toBe(true);
+ });
+
+ it('should handle mixed nested structures', () => {
+ expect(arrayEquals([[{ a: 1 }]], [[{ a: 1 }]])).toBe(true);
+ });
+
+ it('should return false for empty arrays with non-empty arrays', () => {
+ expect(arrayEquals([], [1])).toBe(false);
+ });
+
+ it('should return true for empty arrays', () => {
+ expect(arrayEquals([], [])).toBe(true);
+ });
});
diff --git a/helpers/array/sort.test.ts b/helpers/array/sort.test.ts
index 2940560..65b27aa 100644
--- a/helpers/array/sort.test.ts
+++ b/helpers/array/sort.test.ts
@@ -121,5 +121,189 @@ describe("sort functions", () => {
const sorted = [...items].sort(createSortByDateFn());
expect(sorted.map(i => i.date.getFullYear())).toEqual([2021, 2022, 2023]);
});
+
+ it("should find title property as fallback", () => {
+ const items = [
+ { title: 'z' },
+ { title: 'a' },
+ { title: 'c' }
+ ];
+
+ const sorted = [...items].sort(createSortByStringFn());
+ expect(sorted.map(i => i.title)).toEqual(['a', 'c', 'z']);
+ });
+
+ it("should find description property as fallback", () => {
+ const items = [
+ { description: 'z' },
+ { description: 'a' },
+ { description: 'c' }
+ ];
+
+ const sorted = [...items].sort(createSortByStringFn());
+ expect(sorted.map(i => i.description)).toEqual(['a', 'c', 'z']);
+ });
+
+ it("should handle equal string values", () => {
+ const items = [
+ { value: 'a' },
+ { value: 'a' },
+ { value: 'a' }
+ ];
+
+ const sorted = [...items].sort(createSortByStringFn());
+ expect(sorted.map(i => i.value)).toEqual(['a', 'a', 'a']);
+ });
+
+ it("should handle equal string values case insensitive", () => {
+ const items = [
+ { value: 'A' },
+ { value: 'a' },
+ { value: 'A' }
+ ];
+
+ const sorted = [...items].sort(createSortByStringFn('value', true));
+ expect(sorted.map(i => i.value)).toEqual(['A', 'a', 'A']);
+ });
+
+ it("should sort descending strings correctly", () => {
+ const items = [
+ { value: 'apple' },
+ { value: 'zebra' },
+ { value: 'banana' }
+ ];
+
+ const sorted = [...items].sort((a, b) => createSortByStringFn('value')(b, a));
+ expect(sorted.map(i => i.value)).toEqual(['zebra', 'banana', 'apple']);
+ });
+
+ it("should handle empty string values", () => {
+ const items = [
+ { value: '' },
+ { value: 'a' },
+ { value: '' }
+ ];
+
+ const sorted = [...items].sort(createSortByStringFn());
+ expect(sorted[0].value).toBe('');
+ expect(sorted[1].value).toBe('');
+ expect(sorted[2].value).toBe('a');
+ });
+
+ it("should handle null/undefined values", () => {
+ const items = [
+ { value: null },
+ { value: 'a' },
+ { value: undefined }
+ ];
+
+ const sorted = [...items].sort(createSortByStringFn());
+ expect(sorted[0].value).toBe(null);
+ expect(sorted[1].value).toBe(undefined);
+ expect(sorted[2].value).toBe('a');
+ });
+
+ it("should properly handle case-insensitive sorting", () => {
+ const items = [
+ { value: 'Z' },
+ { value: 'a' },
+ { value: 'B' }
+ ];
+
+ // Case-insensitive: should be a, B, Z (lowercase comparison)
+ const sorted = [...items].sort(createSortByStringFn('value', true));
+ expect(sorted[0].value).toBe('a');
+ expect(sorted[1].value).toBe('B');
+ expect(sorted[2].value).toBe('Z');
+ });
+
+ it("should handle numeric comparison in sortByNumber", () => {
+ const items = [
+ { value: 200 },
+ { value: 10 },
+ { value: 100 }
+ ];
+
+ const sorted = [...items].sort(createSortByNumberFn('value'));
+ // Numeric: 10 < 100 < 200 (not lexical)
+ expect(sorted[0].value).toBe(10);
+ expect(sorted[1].value).toBe(100);
+ expect(sorted[2].value).toBe(200);
+ });
+
+ it("should handle reverse numeric sorting in date", () => {
+ const items = [
+ { timestamp: new Date('2023-01-01') },
+ { timestamp: new Date('2021-01-01') },
+ { timestamp: new Date('2022-01-01') }
+ ];
+
+ const sorted = [...items].sort((a, b) => createSortByDateFn('timestamp')(b, a));
+ expect(sorted[0].timestamp.getFullYear()).toBe(2023);
+ expect(sorted[1].timestamp.getFullYear()).toBe(2022);
+ expect(sorted[2].timestamp.getFullYear()).toBe(2021);
+ });
+
+ it("should handle sensitive and insensitive sorting with same text", () => {
+ const items1 = [
+ { value: 'abc' },
+ { value: 'ABC' },
+ { value: 'AbC' }
+ ];
+
+ // Case-sensitive: uppercase before lowercase
+ const sortedSensitive = [...items1].sort(createSortByStringFn('value', false));
+
+ // Case-insensitive: should maintain relative order when equal
+ const sortedInsensitive = [...items1].sort(createSortByStringFn('value', true));
+
+ expect(sortedSensitive.length).toBe(3);
+ expect(sortedInsensitive.length).toBe(3);
+ });
+
+ it("should sort with default properties when no property specified", () => {
+ const items = [
+ { value: 'z' },
+ { value: 'a' },
+ { value: 'm' }
+ ];
+
+ const sorted = [...items].sort(createSortByStringFn(undefined, false));
+ expect(sorted[0].value).toBe('a');
+ expect(sorted[1].value).toBe('m');
+ expect(sorted[2].value).toBe('z');
+ });
+
+ it("should handle null/undefined property values in string sort", () => {
+ const items = [
+ { name: 'John' },
+ { name: null as any },
+ { name: undefined as any },
+ { name: 'Alice' }
+ ];
+
+ const sorted = [...items].sort(createSortByStringFn('name'));
+ // null/undefined are converted to empty strings, so they come first
+ expect(sorted[0].name ?? '').toBe('');
+ expect(sorted[1].name ?? '').toBe('');
+ expect(sorted[2].name).toBe('Alice');
+ expect(sorted[3].name).toBe('John');
+ });
+
+ it("should handle null/undefined property values in number sort", () => {
+ const items = [
+ { value: 10 },
+ { value: null as any },
+ { value: undefined as any },
+ { value: 5 }
+ ];
+
+ const sorted = [...items].sort(createSortByNumberFn('value'));
+ // null/undefined are converted to 0 by Number() during comparison
+ expect(sorted[0].value ?? 0).toBe(0);
+ expect(sorted[1].value ?? 0).toBe(0);
+ expect(sorted[2].value).toBe(5);
+ expect(sorted[3].value).toBe(10);
+ });
});
});
diff --git a/helpers/date/compare.test.ts b/helpers/date/compare.test.ts
index fa48a2d..ce5fdd3 100644
--- a/helpers/date/compare.test.ts
+++ b/helpers/date/compare.test.ts
@@ -89,8 +89,14 @@ describe('compare', () => {
});
it('should return true for same day with vastly different times', () => {
- const morning = new Date('2023-01-01T06:00:00.000Z');
- const evening = new Date('2023-01-01T23:59:59.999Z');
+ // Create dates in local time to avoid timezone issues
+ const today = new Date();
+ const year = today.getFullYear();
+ const month = today.getMonth();
+ const day = today.getDate();
+
+ const morning = new Date(year, month, day, 6, 0, 0, 0);
+ const evening = new Date(year, month, day, 23, 59, 59, 999);
expect(compare(morning, evening, options)).toBe(true);
});
});
diff --git a/helpers/date/is.test.ts b/helpers/date/is.test.ts
index 10da949..88bf9b5 100644
--- a/helpers/date/is.test.ts
+++ b/helpers/date/is.test.ts
@@ -15,8 +15,14 @@ describe('isSameDay', () => {
});
it('should return true for same day with different times', () => {
- const date1 = new Date('2023-01-01T06:00:00.000Z');
- const date2 = new Date('2023-01-01T23:59:59.999Z');
+ // Create dates in local time to avoid timezone issues
+ const today = new Date();
+ const year = today.getFullYear();
+ const month = today.getMonth();
+ const day = today.getDate();
+
+ const date1 = new Date(year, month, day, 6, 0, 0, 0);
+ const date2 = new Date(year, month, day, 23, 59, 59, 999);
expect(isSameDay(date1, date2)).toBe(true);
});
diff --git a/helpers/date/safeDate.test.ts b/helpers/date/safeDate.test.ts
index e92514d..a7c42b7 100644
--- a/helpers/date/safeDate.test.ts
+++ b/helpers/date/safeDate.test.ts
@@ -35,6 +35,41 @@ describe("safe date utilities", () => {
expect(safeDate(validDate)).toEqual(validDate);
expect(safeDate(invalidDate)).toBe(null);
});
+
+ it("should handle millisecond timestamps", () => {
+ const msTimestamp = 1642694400000; // milliseconds
+ const date = safeDate(msTimestamp);
+ expect(date).toBeInstanceOf(Date);
+ expect(date?.getTime()).toBe(msTimestamp);
+ });
+
+ it("should return null for NaN", () => {
+ expect(safeDate(NaN)).toBe(null);
+ });
+
+ it("should handle date with milliseconds greater than 3-digit", () => {
+ const largeNumber = 999999999999; // Very large timestamp
+ const date = safeDate(largeNumber);
+ expect(date).toBeInstanceOf(Date);
+ expect(date?.getTime()).toBe(largeNumber);
+ });
+
+ it("should handle string dates with various formats", () => {
+ const validDates = [
+ "2022-01-20",
+ "2022/01/20",
+ "01/20/2022",
+ "January 20, 2022",
+ "2022-01-20T10:30:00",
+ "2022-01-20T10:30:00Z"
+ ];
+
+ validDates.forEach(dateStr => {
+ const date = safeDate(dateStr);
+ expect(date).toBeInstanceOf(Date);
+ expect(date?.getFullYear()).toBe(2022);
+ });
+ });
});
describe("dateToISOString", () => {
@@ -47,5 +82,44 @@ describe("safe date utilities", () => {
expect(dateToISOString(null)).toBe(null);
expect(dateToISOString("invalid")).toBe(null);
});
+
+ it("should convert Date objects to ISO string", () => {
+ const date = new Date("2022-01-20T10:00:00Z");
+ const iso = dateToISOString(date);
+ expect(iso).toBe("2022-01-20T10:00:00.000Z");
+ });
+
+ it("should convert timestamps to ISO string", () => {
+ const timestamp = 1642694400000;
+ const iso = dateToISOString(timestamp);
+ expect(iso).not.toBeNull();
+ });
+
+ it("should handle undefined and empty string in dateToISOString", () => {
+ expect(dateToISOString(undefined)).toBe(null);
+ expect(dateToISOString("")).toBe(null);
+ expect(dateToISOString(0)).toBe(null);
+ });
+
+ it("should handle invalid string dates in dateToISOString", () => {
+ expect(dateToISOString("not-a-date")).toBe(null);
+ expect(dateToISOString("12345-invalid")).toBe(null);
+ });
+
+ it("should handle NaN in dateToISOString", () => {
+ expect(dateToISOString(NaN)).toBe(null);
+ });
+
+ it("should handle null input in dateToISOString", () => {
+ expect(dateToISOString(null)).toBe(null);
+ });
+
+ it("should handle unexpected input types gracefully", () => {
+ // Test with types that shouldn't reach safeDate but ensure safety
+ expect(safeDate({} as any)).toBe(null);
+ expect(safeDate([] as any)).toBe(null);
+ expect(safeDate(true as any)).toBe(null);
+ expect(safeDate(false as any)).toBe(null);
+ });
});
});
diff --git a/helpers/date/safeDate.ts b/helpers/date/safeDate.ts
index 878d140..8ac6622 100644
--- a/helpers/date/safeDate.ts
+++ b/helpers/date/safeDate.ts
@@ -31,6 +31,8 @@ export function safeDate(input: string | number | Date | null | undefined): Date
return isNaN(date.getTime()) ? null : date;
}
+ // All valid input types are handled above
+ // This point should never be reached with proper TypeScript types
return null;
}
diff --git a/helpers/object/deepClone.test.ts b/helpers/object/deepClone.test.ts
index dff9d27..2b941c7 100644
--- a/helpers/object/deepClone.test.ts
+++ b/helpers/object/deepClone.test.ts
@@ -44,4 +44,30 @@ describe("deepClone", () => {
expect(cloned).toEqual(date);
expect(cloned).not.toBe(date);
});
+
+ it("should handle undefined", () => {
+ expect(deepClone(undefined)).toBe(undefined);
+ });
+
+ it("should clone objects with multiple nested levels", () => {
+ const original = {
+ a: { b: { c: { d: { e: 'deep' } } } },
+ arr: [1, { nested: [2, 3] }]
+ };
+ const cloned = deepClone(original);
+
+ expect(cloned).toEqual(original);
+ expect(cloned).not.toBe(original);
+ expect(cloned.a.b.c.d).not.toBe(original.a.b.c.d);
+ expect(cloned.arr[1]).not.toBe(original.arr[1]);
+ });
+
+ it("should handle objects with inherited properties", () => {
+ const original = Object.create({ inherited: 'value' });
+ original.own = 'property';
+ const cloned = deepClone(original);
+
+ expect(cloned.own).toBe('property');
+ expect(cloned.inherited).toBeUndefined(); // Only own properties are cloned
+ });
});
diff --git a/helpers/object/deepCompare.test.ts b/helpers/object/deepCompare.test.ts
index 51fb8b9..41b7ef6 100644
--- a/helpers/object/deepCompare.test.ts
+++ b/helpers/object/deepCompare.test.ts
@@ -186,4 +186,90 @@ describe('deepCompare', () => {
expect(result.special).toBe(false); // Maps compared by reference
expect(result.normal.nested.deep).toBe(false); // Normal objects compared deeply
});
+
+ it('should handle unequal primitives in object properties', () => {
+ const obj1 = { num: 42, str: 'hello', bool: true };
+ const obj2 = { num: 43, str: 'world', bool: false };
+ const result = deepCompare(obj1, obj2) as DeepCompareResult;
+ expect(result).toEqual({ num: false, str: false, bool: false });
+ });
+
+ it('should handle comparing objects with various special types', () => {
+ const date1 = new Date('2023-01-01');
+ const date2 = new Date('2023-01-01');
+ const date3 = new Date('2023-01-02');
+
+ expect(deepCompare(date1, date2)).toBe(true);
+ expect(deepCompare(date1, date3)).toBe(false);
+ });
+
+ it('should handle dates in object properties correctly', () => {
+ const obj1 = { created: new Date('2023-01-01'), name: 'test' };
+ const obj2 = { created: new Date('2023-01-01'), name: 'test' };
+ const obj3 = { created: new Date('2023-01-02'), name: 'test' };
+
+ expect(deepCompare(obj1, obj2)).toBe(true);
+ expect(deepCompare(obj1, obj3)).toEqual({ created: false });
+ });
+
+ it('should handle functions in object properties', () => {
+ const func1 = () => { };
+ const func2 = () => { };
+
+ const obj1 = { fn: func1 };
+ const obj2 = { fn: func1 };
+ const obj3 = { fn: func2 };
+
+ expect(deepCompare(obj1, obj2)).toBe(true);
+ expect(deepCompare(obj1, obj3)).toEqual({ fn: false });
+ });
+
+ it('should handle mixed types in properties', () => {
+ const obj1 = { a: null, b: undefined, c: 42 };
+ const obj2 = { a: undefined, b: null, c: '42' };
+ const result = deepCompare(obj1, obj2) as DeepCompareResult;
+ expect(result).toEqual({ a: false, b: false, c: false });
+ });
+
+ it('should handle null/undefined in object property values', () => {
+ const obj1 = { value: null };
+ const obj2 = { value: undefined };
+ const obj3 = { value: true };
+
+ expect(deepCompare(obj1, obj2)).toEqual({ value: false });
+ expect(deepCompare(obj1, obj3)).toEqual({ value: false });
+ expect(deepCompare(obj2, obj3)).toEqual({ value: false });
+ });
+
+ it('should handle nested objects with only-in-A properties', () => {
+ const obj1 = { nested: { a: 1, b: 2 } };
+ const obj2 = { nested: { a: 1 } };
+ const result = deepCompare(obj1, obj2);
+ expect(result).toEqual({ nested: { b: 'onlyA' } });
+ });
+
+ it('should handle deeply nested differences with object result', () => {
+ const obj1 = { level1: { level2: { x: 1, y: 2 } } };
+ const obj2 = { level1: { level2: { x: 1, y: 3 } } };
+ const result = deepCompare(obj1, obj2);
+ expect(result).toEqual({ level1: { level2: { y: false } } });
+ });
+
+ it('should handle nested objects returning false (incompatible types)', () => {
+ // Test where nestedResult is exactly `false` due to type incompatibility
+ const obj1 = { nested: { a: 1 } };
+ const obj2 = { nested: [] as any }; // Array instead of object
+ const result = deepCompare(obj1, obj2);
+ // nested comparison returns false (array vs object)
+ expect(result).toEqual({ nested: false });
+ });
+
+ it('should handle identical nested objects (nestedResult === true)', () => {
+ // Test where nestedResult is exactly `true`
+ const obj1 = { nested: { x: 1, y: 2 }, other: 'different' };
+ const obj2 = { nested: { x: 1, y: 2 }, other: 'value' };
+ const result = deepCompare(obj1, obj2);
+ // nested is identical (true), but other differs
+ expect(result).toEqual({ other: false });
+ });
});
diff --git a/helpers/object/deepCompare.ts b/helpers/object/deepCompare.ts
index 6c2046e..cade218 100644
--- a/helpers/object/deepCompare.ts
+++ b/helpers/object/deepCompare.ts
@@ -66,7 +66,8 @@ export function deepCompare(objA: object | undefined | null, objB: object | unde
differences[key] = "onlyB";
} else if (hasA && !hasB) {
differences[key] = "onlyA";
- } else if (hasA && hasB) {
+ } else {
+ // Both objects have this key - compare values
const valueA = (objA as any)[key];
const valueB = (objB as any)[key];
diff --git a/helpers/object/deepMerge.test.ts b/helpers/object/deepMerge.test.ts
index 3f3f50a..17fd925 100644
--- a/helpers/object/deepMerge.test.ts
+++ b/helpers/object/deepMerge.test.ts
@@ -40,4 +40,72 @@ describe("deepMerge", () => {
expect(target.b).toHaveProperty('d', 3);
expect(target.b).toHaveProperty('c', 2);
});
+
+ it("should return target when no sources provided", () => {
+ const target = { a: 1 };
+ const result = deepMerge(target);
+ expect(result).toEqual({ a: 1 });
+ });
+
+ it("should handle undefined values in source", () => {
+ const target = { a: 1 };
+ const source = { b: undefined };
+ const result = deepMerge(target, source);
+ expect(result).toEqual({ a: 1 });
+ expect('b' in result).toBe(false);
+ });
+
+ it("should handle null values in source", () => {
+ const target = { a: 1 };
+ const source = { b: null };
+ const result = deepMerge(target, source);
+ expect(result).toEqual({ a: 1, b: null });
+ });
+
+ it("should handle arrays as values (not merge them)", () => {
+ const target = { arr: [1, 2] };
+ const source = { arr: [3, 4] };
+ const result = deepMerge(target, source);
+ expect(result.arr).toEqual([3, 4]);
+ });
+
+ it("should deeply merge multiple nested objects", () => {
+ const target = { a: { b: { c: 1 } } };
+ const source1 = { a: { b: { d: 2 } } };
+ const source2 = { a: { e: 3 } };
+ const result = deepMerge(target, source1, source2);
+ expect(result).toEqual({
+ a: { b: { c: 1, d: 2 }, e: 3 }
+ });
+ });
+
+ it("should handle multiple sources with no common properties", () => {
+ const target = { x: 1 };
+ const source1 = { y: 2 };
+ const source2 = { z: 3 };
+ const result = deepMerge(target, source1, source2);
+ expect(result).toEqual({ x: 1, y: 2, z: 3 });
+ });
+
+ it("should handle merging with nested null values correctly", () => {
+ const target = { a: { b: null } };
+ const source = { a: { c: 3 } };
+ const result = deepMerge(target, source);
+ expect(result).toEqual({ a: { b: null, c: 3 } });
+ });
+
+ it("should handle multiple sources including empty objects", () => {
+ const target = { a: 1 };
+ const source1 = { b: 2 };
+ const source2 = {};
+ const source3 = { c: 3 };
+ const result = deepMerge(target, source1, source2, source3);
+ expect(result).toEqual({ a: 1, b: 2, c: 3 });
+ });
+
+ it("should handle null/undefined sources gracefully", () => {
+ const target = { a: 1 };
+ const result = deepMerge(target, null as any, undefined as any, { b: 2 });
+ expect(result).toEqual({ a: 1, b: 2 });
+ });
});
diff --git a/helpers/object/quickCompare.test.ts b/helpers/object/quickCompare.test.ts
index 262e960..ba28379 100644
--- a/helpers/object/quickCompare.test.ts
+++ b/helpers/object/quickCompare.test.ts
@@ -71,4 +71,35 @@ describe('quickCompare', () => {
// This is a known limitation of quickCompare
expect(quickCompare(obj1, obj2)).toBe(false);
});
+
+ it('should handle circular references by falling back to === comparison', () => {
+ const obj1: any = { a: 1 };
+ obj1.self = obj1;
+ const obj2: any = { a: 1 };
+ obj2.self = obj2;
+
+ expect(quickCompare(obj1, obj1)).toBe(true); // Same reference
+ expect(quickCompare(obj1, obj2)).toBe(false); // Different references
+ });
+
+ it('should handle dates', () => {
+ const date1 = new Date('2023-01-01');
+ const date2 = new Date('2023-01-01');
+ const date3 = new Date('2023-01-02');
+
+ expect(quickCompare(date1, date2)).toBe(true);
+ expect(quickCompare(date1, date3)).toBe(false);
+ });
+
+ it('should handle mixed types', () => {
+ expect(quickCompare(1, '1')).toBe(false);
+ expect(quickCompare([], {})).toBe(false);
+ expect(quickCompare(null, 0)).toBe(false);
+ });
+
+ it('should handle objects with undefined values', () => {
+ const obj1 = { a: 1, b: undefined };
+ const obj2 = { a: 1, b: undefined };
+ expect(quickCompare(obj1, obj2)).toBe(true);
+ });
});
diff --git a/helpers/string/errorToReadableMessage.test.ts b/helpers/string/errorToReadableMessage.test.ts
index ca26d7e..402f828 100644
--- a/helpers/string/errorToReadableMessage.test.ts
+++ b/helpers/string/errorToReadableMessage.test.ts
@@ -12,49 +12,111 @@ import { expect, test, describe } from "vitest";
import { errorToReadableMessage } from "./errorToReadableMessage";
describe('errorToReadableMessage', () => {
- test("should return error when type of string", async () =>
+ // Basic cases
+ test("should return undefined when error is null", () =>
+ expect(errorToReadableMessage(null)).toBeUndefined());
+
+ test("should return undefined when error is undefined", () =>
+ expect(errorToReadableMessage(undefined)).toBeUndefined());
+
+ test("should return error when type is string", () =>
expect(errorToReadableMessage("unexpected error")).toBe("unexpected error"));
- test("should return errorMessage when present in error", async () =>
- expect(errorToReadableMessage({ error: { errorMessage: "unexpected error" } })).toBe(
- "unexpected error"
+ test("should return string for number error", () =>
+ expect(errorToReadableMessage(123)).toBe("123"));
+
+ test("should return string for boolean error", () =>
+ expect(errorToReadableMessage(true)).toBe("true"));
+
+ // Nested error - errorMessage
+ test("should return errorMessage when present in nested error", () =>
+ expect(errorToReadableMessage({ error: { errorMessage: "keycloak error" } })).toBe(
+ "keycloak error"
));
- test("should return error when present in error", async () =>
- expect(errorToReadableMessage({ error: "unexpected error" })).toBe(
- "unexpected error"
+ // Nested error - direct string
+ test("should return error when present in error as string", () =>
+ expect(errorToReadableMessage({ error: "direct error" })).toBe(
+ "direct error"
));
- test("should return message when present in error", async () =>
- expect(errorToReadableMessage({ message: "unexpected error" })).toBe(
- "unexpected error"
+ // Nested error - deep nesting
+ test("should handle deeply nested errors", () =>
+ expect(errorToReadableMessage({ error: { error: { error: "deep error" } } })).toBe(
+ "deep error"
));
- test("should return stringified error in all other cases", async () =>
- expect(errorToReadableMessage({ customError: "unexpected error" }, true)).toBe(
- '{"customError":"unexpected error"}'
+ // Message field
+ test("should return message when present in error", () =>
+ expect(errorToReadableMessage({ message: "error message" })).toBe(
+ "error message"
));
-});
+ // Error instance
+ test("should return message from Error instance", () => {
+ const err = new Error("error instance message");
+ expect(errorToReadableMessage(err)).toBe("Error: error instance message");
+ });
-/*
-old code
-
-export function errorToString(error: any): string {
- if (!error) {
- return "Un unexpected error occurred";
- } else if (typeof error === "string") {
- return error;
- } else if (error?.error?.errorMessage) {
- // Keycloak specific error
- return error.error.errorMessage;
- } else if ("error" in error) {
- return errorToString(error.error);
- } else if (error instanceof Error || "message" in error) {
- return error.message;
- } else {
- return JSON.stringify(error);
- }
-}
-
-*/
\ No newline at end of file
+ // TypeError instance
+ test("should return message from TypeError instance", () => {
+ const err = new TypeError("type error message");
+ expect(errorToReadableMessage(err)).toBe("TypeError: type error message");
+ });
+
+ // OAuth errors - code_error
+ test("should handle OAuth code_error with reason and params", () => {
+ const oauthError = {
+ type: "code_error",
+ reason: "auth_failed",
+ params: {
+ error: "invalid_grant",
+ error_description: "Invalid credentials"
+ }
+ };
+ expect(errorToReadableMessage(oauthError)).toBe(
+ "invalid_grant: Invalid credentials"
+ );
+ });
+
+ // OAuth errors - other type
+ test("should handle OAuth errors with other type", () => {
+ const oauthError = {
+ type: "auth_error",
+ reason: "session_expired",
+ params: {
+ error: "invalid_session",
+ error_description: "Session expired"
+ }
+ };
+ expect(errorToReadableMessage(oauthError)).toBe(
+ "auth_error: session_expired"
+ );
+ });
+
+ // Stringify parameter - true
+ test("should stringify error when stringify is true", () =>
+ expect(errorToReadableMessage({ customError: "value" }, true)).toBe(
+ '{"customError":"value"}'
+ ));
+
+ // Stringify parameter - false (default)
+ test("should return undefined with unknown error object and stringify false", () =>
+ expect(errorToReadableMessage({ customError: "value" }, false)).toBeUndefined());
+
+ // Stringify parameter - custom string
+ test("should return custom fallback when provided", () =>
+ expect(errorToReadableMessage({ unknownError: true }, "default message")).toBe(
+ "default message"
+ ));
+
+ // Complex custom object with custom stringify
+ test("should stringify complex object with custom message", () => {
+ const result = errorToReadableMessage(
+ { code: 500, details: { nested: true } },
+ true
+ );
+ expect(result).toContain('"code":500');
+ expect(result).toContain('"nested":true');
+ });
+});
\ No newline at end of file
diff --git a/helpers/type/isEmpty.test.ts b/helpers/type/isEmpty.test.ts
new file mode 100644
index 0000000..66fef69
--- /dev/null
+++ b/helpers/type/isEmpty.test.ts
@@ -0,0 +1,57 @@
+/**
+ * This file is part of helpers4.
+ * Copyright (C) 2025 baxyz
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it } from 'vitest';
+import { isEmpty } from './isEmpty';
+
+describe('isEmpty', () => {
+ it('should treat null and undefined as empty', () => {
+ expect(isEmpty(null)).toBe(true);
+ expect(isEmpty(undefined)).toBe(true);
+ });
+
+ it('should handle strings', () => {
+ expect(isEmpty('')).toBe(true);
+ expect(isEmpty(' ')).toBe(false);
+ expect(isEmpty('text')).toBe(false);
+ });
+
+ it('should handle arrays', () => {
+ expect(isEmpty([])).toBe(true);
+ expect(isEmpty([1])).toBe(false);
+ });
+
+ it('should handle plain objects', () => {
+ expect(isEmpty({})).toBe(true);
+ expect(isEmpty({ a: 1 })).toBe(false);
+ });
+
+ it('should handle objects with null prototype', () => {
+ const obj = Object.create(null) as Record;
+ expect(isEmpty(obj)).toBe(true);
+ obj.key = 'value';
+ expect(isEmpty(obj)).toBe(false);
+ });
+
+ it('should handle Map and Set', () => {
+ expect(isEmpty(new Map())).toBe(true);
+ expect(isEmpty(new Set())).toBe(true);
+ expect(isEmpty(new Map([['key', 'value']]))).toBe(false);
+ expect(isEmpty(new Set([1]))).toBe(false);
+ });
+
+ it('should return false for special objects', () => {
+ expect(isEmpty(new Date())).toBe(false);
+ class Example {}
+ expect(isEmpty(new Example())).toBe(false);
+ });
+
+ it('should return false for numbers, booleans and functions', () => {
+ expect(isEmpty(0)).toBe(false);
+ expect(isEmpty(false)).toBe(false);
+ expect(isEmpty(() => undefined)).toBe(false);
+ });
+});
diff --git a/helpers/type/isEmpty.ts b/helpers/type/isEmpty.ts
new file mode 100644
index 0000000..f2de01c
--- /dev/null
+++ b/helpers/type/isEmpty.ts
@@ -0,0 +1,59 @@
+/**
+ * This file is part of helpers4.
+ * Copyright (C) 2025 baxyz
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { isSpecialObject } from './isSpecialObject';
+
+/**
+ * Checks if a value is empty.
+ *
+ * Supported types:
+ * - `null` / `undefined` → empty
+ * - `string` → length === 0
+ * - `array` → length === 0
+ * - `Map` / `Set` → size === 0
+ * - plain object → no own enumerable properties
+ *
+ * @param value - The value to check
+ * @returns `true` if the value is considered empty, `false` otherwise
+ *
+ * @example
+ * isEmpty('') // true
+ * isEmpty([]) // true
+ * isEmpty({}) // true
+ * isEmpty('foo') // false
+ */
+export function isEmpty(value: unknown): boolean {
+ if (value === null || value === undefined) {
+ return true;
+ }
+
+ if (typeof value === 'string') {
+ return value.length === 0;
+ }
+
+ if (Array.isArray(value)) {
+ return value.length === 0;
+ }
+
+ if (value instanceof Map || value instanceof Set) {
+ return value.size === 0;
+ }
+
+ if (typeof value === 'object') {
+ if (isSpecialObject(value)) {
+ return false;
+ }
+
+ const proto = Object.getPrototypeOf(value);
+ if (proto !== Object.prototype && proto !== null) {
+ return false;
+ }
+
+ return Object.keys(value as Record).length === 0;
+ }
+
+ return false;
+}
diff --git a/helpers/type/isSpecialObject.test.ts b/helpers/type/isSpecialObject.test.ts
index af08204..541536a 100644
--- a/helpers/type/isSpecialObject.test.ts
+++ b/helpers/type/isSpecialObject.test.ts
@@ -88,4 +88,27 @@ describe('isSpecialObject', () => {
objWithoutConstructor.someProperty = 'value';
expect(isSpecialObject(objWithoutConstructor)).toBe(false);
});
+
+ it('should return true for HTMLElement in browser', () => {
+ // In happy-dom, HTMLElement may or may not be fully implemented
+ // Test that our code doesn't crash when checking HTMLElement
+ try {
+ const element = document.createElement('div');
+ expect(isSpecialObject(element)).toBe(true);
+ } catch {
+ // If HTMLElement is not available, just skip
+ expect(true).toBe(true);
+ }
+ });
+
+ it('should return true for built-in types by constructor name', () => {
+ // Test Buffer
+ const buffer = Buffer.from('test');
+ expect(isSpecialObject(buffer)).toBe(true);
+ });
+
+ it('should return false for objects with non-matching constructor names', () => {
+ const customObj = { constructor: { name: 'CustomClass' } };
+ expect(isSpecialObject(customObj)).toBe(false);
+ });
});
diff --git a/helpers/url/extractPureURI.test.ts b/helpers/url/extractPureURI.test.ts
index cbd9611..0943810 100644
--- a/helpers/url/extractPureURI.test.ts
+++ b/helpers/url/extractPureURI.test.ts
@@ -18,6 +18,14 @@ describe('extractPureURI', () => {
expect(extractPureURI("www.foo.com/api/#userInfos")).toBe(
"www.foo.com/api/"
));
+ test("should extract URI from URL with both query and fragment, taking query first", () =>
+ expect(extractPureURI("www.foo.com/api/?param=1#anchor")).toBe(
+ "www.foo.com/api/"
+ ));
+ test("should extract URI from URL with both query and fragment, taking fragment first", () =>
+ expect(extractPureURI("www.foo.com/api/#anchor?param=1")).toBe(
+ "www.foo.com/api/"
+ ));
test("should do nothing from empty string", () =>
expect(extractPureURI("")).toBe(""));
test("should do nothing from standalone slash", () =>
@@ -30,4 +38,6 @@ describe('extractPureURI', () => {
expect(extractPureURI(undefined)).toBe(undefined));
test("should handle null", () =>
expect(extractPureURI(null)).toBe(null));
+ test("should handle URL with only fragment and query together", () =>
+ expect(extractPureURI("path#anchor?query")).toBe("path"));
});
diff --git a/helpers/version/compare.test.ts b/helpers/version/compare.test.ts
index a754475..811c7d5 100644
--- a/helpers/version/compare.test.ts
+++ b/helpers/version/compare.test.ts
@@ -133,5 +133,66 @@ describe("compare", () => {
expect(compare("v1.0.0-alpha", "v1.0.0-beta")).toBe(-1);
expect(compare("v1.0.0-alpha", "1.0.0-alpha")).toBe(0);
});
+
+ it("should handle complex version strings with zeros", () => {
+ expect(compare("0.0.0", "0.0.1")).toBe(-1);
+ expect(compare("1.0.0", "1.0.0-rc.1")).toBe(1);
+ expect(compare("2.0.0", "1.9.9")).toBe(1);
+ });
+
+ it("should handle longer prerelease arrays with mixed numeric and alphanumeric", () => {
+ expect(compare("1.0.0-alpha.1.2", "1.0.0-alpha.1.3")).toBe(-1);
+ expect(compare("1.0.0-1.2.3", "1.0.0-1.2.4")).toBe(-1);
+ expect(compare("1.0.0-rc.1.beta.2", "1.0.0-rc.1.beta.10")).toBe(-1);
+ });
+
+ it("should handle numeric prerelease identifiers correctly", () => {
+ expect(compare("1.0.0-10", "1.0.0-9")).toBe(1); // 10 > 9 numerically
+ expect(compare("1.0.0-2.10", "1.0.0-2.9")).toBe(1); // 10 > 9 numerically
+ expect(compare("1.0.0-10", "1.0.0-a")).toBe(-1); // numeric < alpha
+ });
+
+ it("should handle mixed prerelease and no prerelease", () => {
+ expect(compare("1.0.0", "1.0.0-0")).toBe(1); // release > any prerelease
+ expect(compare("1.0.0-a", "1.0.0")).toBe(-1); // prerelease < release
+ expect(compare("2.0.0-zzz", "1.0.0")).toBe(1); // major difference dominates
+ });
+
+ it("should handle equal numeric identifiers in prerelease", () => {
+ // Test case where numeric identifiers are equal, should continue to next
+ expect(compare("1.0.0-1.1", "1.0.0-1.2")).toBe(-1); // First id equal (1==1), compare second (1<2)
+ expect(compare("1.0.0-5.5", "1.0.0-5.10")).toBe(-1); // First id equal (5==5), compare second (5<10)
+ });
+
+ it("should handle equal alphanumeric identifiers in prerelease", () => {
+ // Test case where alphanumeric identifiers are equal, should continue to next
+ expect(compare("1.0.0-alpha.1", "1.0.0-alpha.2")).toBe(-1); // First id equal (alpha==alpha), compare second (1<2)
+ expect(compare("1.0.0-beta.x", "1.0.0-beta.y")).toBe(-1); // First id equal (beta==beta), compare second (x alpha
+ expect(compare("1.0.0-gamma", "1.0.0-alpha")).toBe(1); // gamma > alpha
+ // Test alphanumeric equal in else block (exercises both id1x (else branch of if id1 {
+ // Test case where first numeric identifiers loop through equality with continue, then differ
+ expect(compare("1.0.0-1.1.2", "1.0.0-1.1.3")).toBe(-1); // 1==1 (continue), 1==1 (continue), 2<3
+ expect(compare("1.0.0-5.5.5", "1.0.0-5.5.6")).toBe(-1); // 5==5 (continue), 5==5 (continue), 5<6
+ // Test with leading zeros (numeric comparison) - "01" and "1" should be treated as equal numbers
+ expect(compare("1.0.0-01.1", "1.0.0-1.2")).toBe(-1); // parseInt("01") === parseInt("1") (1==1), continue, then 1<2
+ expect(compare("1.0.0-05.2", "1.0.0-5.3")).toBe(-1); // parseInt("05") === parseInt("5") (5==5), continue, then 2<3
+ // Test numeric identifiers that are equal (covers continue in numeric block)
+ expect(compare("1.0.0-1.2.3", "1.0.0-1.2.4")).toBe(-1); // 1==1 (continue), 2==2 (continue), 3<4
+ });
+
+ it("should handle multiple equal alphanumeric identifiers with final difference", () => {
+ // Test case where first alphanumeric identifiers loop through equality with continue, then differ
+ expect(compare("1.0.0-alpha.beta.1", "1.0.0-alpha.beta.2")).toBe(-1); // alpha==alpha (continue), beta==beta (continue), 1<2
+ expect(compare("1.0.0-a.b.x", "1.0.0-a.b.y")).toBe(-1); // a==a (continue), b==b (continue), x num2) return 1;
- continue;
+ // num1 === num2, continue to next identifier
}
-
// Numeric has lower precedence than alphanumeric
- if (isNum1) return -1;
- if (isNum2) return 1;
-
+ else if (isNum1) {
+ return -1;
+ } else if (isNum2) {
+ return 1;
+ }
// Both alphanumeric: compare lexically (ASCII sort)
- if (id1 < id2) return -1;
- if (id1 > id2) return 1;
+ else {
+ if (id1 < id2) return -1;
+ if (id1 > id2) return 1;
+ // id1 === id2, continue to next identifier
+ }
}
return 0;
diff --git a/helpers/version/satisfiesRange.test.ts b/helpers/version/satisfiesRange.test.ts
index da88eb8..2aefff6 100644
--- a/helpers/version/satisfiesRange.test.ts
+++ b/helpers/version/satisfiesRange.test.ts
@@ -49,4 +49,52 @@ describe("satisfiesRange", () => {
expect(satisfiesRange("1.3.0", "~1.2.0")).toBe(false);
expect(satisfiesRange("1.1.9", "~1.2.0")).toBe(false);
});
+
+ it("should handle v prefix in ranges", () => {
+ expect(satisfiesRange("v1.2.3", "^v1.0.0")).toBe(true);
+ expect(satisfiesRange("1.2.3", "^v1.0.0")).toBe(true);
+ expect(satisfiesRange("v1.2.3", "^1.0.0")).toBe(true);
+ });
+
+ it("should handle three-part version comparisons", () => {
+ expect(satisfiesRange("1.2.5", ">=1.2.3")).toBe(true);
+ expect(satisfiesRange("1.2.3", ">=1.2.3")).toBe(true);
+ expect(satisfiesRange("1.2.2", ">=1.2.3")).toBe(false);
+ });
+
+ it("should handle with minor version 0", () => {
+ expect(satisfiesRange("1.0.5", "~1.0.0")).toBe(true);
+ expect(satisfiesRange("1.0.0", "~1.0.0")).toBe(true);
+ expect(satisfiesRange("1.1.0", "~1.0.0")).toBe(false);
+ });
+
+ it("should return false for unrecognized range format", () => {
+ expect(satisfiesRange("1.2.3", "invalid-range")).toBe(false);
+ });
+
+ it("should handle caret ranges with different patch levels", () => {
+ expect(satisfiesRange("1.0.5", "^1.0.0")).toBe(true);
+ expect(satisfiesRange("1.5.0", "^1.0.0")).toBe(true);
+ expect(satisfiesRange("1.99.99", "^1.0.0")).toBe(true);
+ });
+
+ it("should handle tilde ranges with different patch levels", () => {
+ expect(satisfiesRange("1.2.1", "~1.2.0")).toBe(true);
+ expect(satisfiesRange("1.2.99", "~1.2.0")).toBe(true);
+ expect(satisfiesRange("1.2.0", "~1.2.0")).toBe(true);
+ });
+
+ it("should handle version with missing patch", () => {
+ expect(satisfiesRange("1.2", ">=1.0")).toBe(true);
+ expect(satisfiesRange("1.0", "^1.0")).toBe(true);
+ });
+
+ it("should handle invalid range formats", () => {
+ expect(satisfiesRange("1.2.3", "invalid-range")).toBe(false);
+ expect(satisfiesRange("1.0.0", "!!1.0.0")).toBe(false);
+ expect(satisfiesRange("1.0.0", "@1.0.0")).toBe(false);
+ // Test format that has an operator character but no valid prefix (=> unsupported range format)
+ expect(satisfiesRange("1.0.0", "~")).toBe(false);
+ expect(satisfiesRange("1.0.0", "=1.0.0")).toBe(false); // '=' alone is not '>=', '<=', or exact match
+ });
});
diff --git a/helpers/version/satisfiesRange.ts b/helpers/version/satisfiesRange.ts
index 44b727f..60252ea 100644
--- a/helpers/version/satisfiesRange.ts
+++ b/helpers/version/satisfiesRange.ts
@@ -62,9 +62,10 @@ export function satisfiesRange(version: string, range: string): boolean {
return versionMajor === targetMajor &&
versionMinor === targetMinor &&
compareVersionsSimple(normalizedVersion, targetVersion) >= 0;
+ } else {
+ // Unsupported range format
+ return false;
}
-
- return false;
}
function compareVersionsSimple(version1: string, version2: string): number {
diff --git a/vitest.config.ts b/vitest.config.ts
index eafb538..d6c188a 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -14,15 +14,12 @@ export default defineConfig({
provider: 'v8',
reporter: ['text', 'json', 'json-summary', 'html'],
include: ['helpers/**/*.ts'],
- exclude: ['helpers/**/*.{test,spec}.ts', 'helpers/**/index.ts'],
- // Target: 100% coverage
- // Current thresholds are set to warn but not fail
- // Remove these comments and set all to 100 when ready
+ exclude: ['helpers/**/*.{test,spec,bench}.ts', 'helpers/**/index.ts'],
thresholds: {
- lines: 80,
- functions: 80,
- branches: 80,
- statements: 80
+ lines: 100,
+ functions: 100,
+ branches: 100,
+ statements: 100
}
}
}