fix(build): frontend build determinism across line endings (DC-119) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

The deploy host showed phantom dist drift on every git pull: committed
dist bundles could not be reproduced on DNS2. Root cause (verified with
esbuild 0.25.12 probes): the production transform uses sourcemap:'both',
whose inline map base64-embeds RAW source bytes as sourcesContent — a CRLF
working copy (Windows dev, core.autocrlf=true) vs an LF checkout produces
different dist bytes and a different sw.js cache tag.

- build.js: normalizeSource (\r\n -> \n) on every source read (bundle
  inputs + sw.js read); exported + require.main guard so tests can import
  it without triggering a build
- .gitattributes: * text=auto eol=lf (git-layer kill of the CRLF vector)
  + binary exclusions; 9 CRLF-in-index asset files renormalized
- tests/build-determinism.test.js: 3-case pin (byte-identity after
  normalization, divergence pre-normalization, CR-strip contract) importing
  the ACTUAL normalizeSource from build.js
- package.json: declare jsdom devDependency — 2 committed test files
  require('jsdom') but it was never declared, so fresh-checkout
  npm test failed (it only passed where a stray ancestor node_modules
  happened to contain it)
- dist/features.js + sw.js: canonical deterministic rebuild
  (cache tag 3354f5fd96 -> d39ab69dd4)

Verified: status 54/54, API 2858/2858 (128 suites); CRLF-sim tree and LF
tree of same HEAD produce byte-identical dist artifacts (sha256-equal).
Judge: glm-5.3 cold round 1 = A, round 2 (post-fold) = A clean, 0 blocking.
This commit is contained in:
Hermes
2026-08-23 16:34:49 -07:00
parent 1744d1c86e
commit 4d97a11978
16 changed files with 3065 additions and 2319 deletions
+282 -282
View File
@@ -1,282 +1,282 @@
/**
* Progress Tracker
* Manages persistent storage of user progress through the onboarding flow
* using browser local storage.
*
* Storage Schema:
* {
* "version": "1.0",
* "tourCompleted": false,
* "completedTooltips": ["welcome", "dns-priority", ...],
* "currentStep": 3,
* "completionTimestamp": "2024-01-15T10:30:00Z",
* "dnsSetupDeferred": false,
* "lastVisit": "2024-01-15T10:30:00Z"
* }
*/
(function(window) {
'use strict';
/**
* ProgressTracker class
* Manages persistent storage of onboarding progress
*
* @class
* @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding')
*/
class ProgressTracker {
constructor(storageKey = 'dashcaddy_onboarding') {
this.storageKey = storageKey;
this.storageVersion = '1.0';
// Initialize storage if it doesn't exist
this._initializeStorage();
// Update last visit timestamp
this._updateLastVisit();
}
/**
* Initialize storage with default values if it doesn't exist
* @private
*/
_initializeStorage() {
const existing = this._getStorage();
if (!existing || existing.version !== this.storageVersion) {
const defaultState = {
version: this.storageVersion,
tourCompleted: false,
completedTooltips: [],
currentStep: 0,
completionTimestamp: null,
dnsSetupDeferred: false,
lastVisit: new Date().toISOString()
};
this._setStorage(defaultState);
}
}
/**
* Get the current storage state
* @private
* @returns {Object|null} The storage state or null if unavailable
*/
_getStorage() {
try {
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('[ProgressTracker] Error reading from storage:', error);
return null;
}
}
/**
* Set the storage state
* @private
* @param {Object} state - The state to save
*/
_setStorage(state) {
try {
localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (error) {
console.error('[ProgressTracker] Error writing to storage:', error);
// Handle quota exceeded or storage unavailable
// Fall back to session storage or in-memory storage
this._handleStorageError(error);
}
}
/**
* Handle storage errors (quota exceeded, unavailable, etc.)
* @private
* @param {Error} error - The error that occurred
*/
_handleStorageError(error) {
// Try session storage as fallback
try {
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
console.warn('[ProgressTracker] Falling back to session storage');
} catch (sessionError) {
console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
// Could implement in-memory fallback here if needed
}
}
/**
* Update the last visit timestamp
* @private
*/
_updateLastVisit() {
const state = this._getStorage();
if (state) {
state.lastVisit = new Date().toISOString();
this._setStorage(state);
}
}
/**
* Check if a specific tooltip has been completed
* @param {string} tooltipId - The ID of the tooltip to check
* @returns {boolean} True if the tooltip has been completed
*/
isTooltipCompleted(tooltipId) {
const state = this._getStorage();
if (!state) return false;
return state.completedTooltips.includes(tooltipId);
}
/**
* Mark a tooltip as completed with timestamp
* @param {string} tooltipId - The ID of the tooltip to mark as completed
*/
markTooltipCompleted(tooltipId) {
const state = this._getStorage();
if (!state) return;
// Add tooltip to completed list if not already there
if (!state.completedTooltips.includes(tooltipId)) {
state.completedTooltips.push(tooltipId);
// Store timestamp for this specific tooltip
if (!state.tooltipTimestamps) {
state.tooltipTimestamps = {};
}
state.tooltipTimestamps[tooltipId] = new Date().toISOString();
this._setStorage(state);
}
}
/**
* Check if the entire tour has been completed
* @returns {boolean} True if the tour is completed
*/
isTourCompleted() {
const state = this._getStorage();
if (!state) return false;
return state.tourCompleted === true;
}
/**
* Mark the entire tour as completed
*/
markTourCompleted() {
const state = this._getStorage();
if (!state) return;
state.tourCompleted = true;
state.completionTimestamp = new Date().toISOString();
this._setStorage(state);
}
/**
* Get the current step index
* @returns {number} The current step index (0-based)
*/
getCurrentStep() {
const state = this._getStorage();
if (!state) return 0;
return state.currentStep || 0;
}
/**
* Set the current step index
* @param {number} stepIndex - The step index to set (0-based)
*/
setCurrentStep(stepIndex) {
const state = this._getStorage();
if (!state) return;
state.currentStep = stepIndex;
this._setStorage(state);
}
/**
* Reset all progress and clear storage
*/
resetProgress() {
const defaultState = {
version: this.storageVersion,
tourCompleted: false,
completedTooltips: [],
currentStep: 0,
completionTimestamp: null,
dnsSetupDeferred: false,
lastVisit: new Date().toISOString()
};
this._setStorage(defaultState);
}
/**
* Get the completion timestamp
* @returns {Date|null} The completion timestamp or null if not completed
*/
getCompletionTimestamp() {
const state = this._getStorage();
if (!state || !state.completionTimestamp) return null;
return new Date(state.completionTimestamp);
}
/**
* Check if DNS setup was deferred
* @returns {boolean} True if DNS setup was deferred
*/
isDnsSetupDeferred() {
const state = this._getStorage();
if (!state) return false;
return state.dnsSetupDeferred === true;
}
/**
* Mark DNS setup as deferred
*/
markDnsSetupDeferred() {
const state = this._getStorage();
if (!state) return;
state.dnsSetupDeferred = true;
this._setStorage(state);
}
/**
* Get the timestamp for a specific tooltip completion
* @param {string} tooltipId - The ID of the tooltip
* @returns {Date|null} The timestamp or null if not completed
*/
getTooltipTimestamp(tooltipId) {
const state = this._getStorage();
if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) {
return null;
}
return new Date(state.tooltipTimestamps[tooltipId]);
}
/**
* Get all completed tooltip IDs
* @returns {string[]} Array of completed tooltip IDs
*/
getCompletedTooltips() {
const state = this._getStorage();
if (!state) return [];
return state.completedTooltips || [];
}
/**
* Get the last visit timestamp
* @returns {Date|null} The last visit timestamp
*/
getLastVisit() {
const state = this._getStorage();
if (!state || !state.lastVisit) return null;
return new Date(state.lastVisit);
}
}
// Export to global scope
window.ProgressTracker = ProgressTracker;
console.log('[ProgressTracker] Module loaded');
})(window);
/**
* Progress Tracker
* Manages persistent storage of user progress through the onboarding flow
* using browser local storage.
*
* Storage Schema:
* {
* "version": "1.0",
* "tourCompleted": false,
* "completedTooltips": ["welcome", "dns-priority", ...],
* "currentStep": 3,
* "completionTimestamp": "2024-01-15T10:30:00Z",
* "dnsSetupDeferred": false,
* "lastVisit": "2024-01-15T10:30:00Z"
* }
*/
(function(window) {
'use strict';
/**
* ProgressTracker class
* Manages persistent storage of onboarding progress
*
* @class
* @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding')
*/
class ProgressTracker {
constructor(storageKey = 'dashcaddy_onboarding') {
this.storageKey = storageKey;
this.storageVersion = '1.0';
// Initialize storage if it doesn't exist
this._initializeStorage();
// Update last visit timestamp
this._updateLastVisit();
}
/**
* Initialize storage with default values if it doesn't exist
* @private
*/
_initializeStorage() {
const existing = this._getStorage();
if (!existing || existing.version !== this.storageVersion) {
const defaultState = {
version: this.storageVersion,
tourCompleted: false,
completedTooltips: [],
currentStep: 0,
completionTimestamp: null,
dnsSetupDeferred: false,
lastVisit: new Date().toISOString()
};
this._setStorage(defaultState);
}
}
/**
* Get the current storage state
* @private
* @returns {Object|null} The storage state or null if unavailable
*/
_getStorage() {
try {
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('[ProgressTracker] Error reading from storage:', error);
return null;
}
}
/**
* Set the storage state
* @private
* @param {Object} state - The state to save
*/
_setStorage(state) {
try {
localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (error) {
console.error('[ProgressTracker] Error writing to storage:', error);
// Handle quota exceeded or storage unavailable
// Fall back to session storage or in-memory storage
this._handleStorageError(error);
}
}
/**
* Handle storage errors (quota exceeded, unavailable, etc.)
* @private
* @param {Error} error - The error that occurred
*/
_handleStorageError(error) {
// Try session storage as fallback
try {
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
console.warn('[ProgressTracker] Falling back to session storage');
} catch (sessionError) {
console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
// Could implement in-memory fallback here if needed
}
}
/**
* Update the last visit timestamp
* @private
*/
_updateLastVisit() {
const state = this._getStorage();
if (state) {
state.lastVisit = new Date().toISOString();
this._setStorage(state);
}
}
/**
* Check if a specific tooltip has been completed
* @param {string} tooltipId - The ID of the tooltip to check
* @returns {boolean} True if the tooltip has been completed
*/
isTooltipCompleted(tooltipId) {
const state = this._getStorage();
if (!state) return false;
return state.completedTooltips.includes(tooltipId);
}
/**
* Mark a tooltip as completed with timestamp
* @param {string} tooltipId - The ID of the tooltip to mark as completed
*/
markTooltipCompleted(tooltipId) {
const state = this._getStorage();
if (!state) return;
// Add tooltip to completed list if not already there
if (!state.completedTooltips.includes(tooltipId)) {
state.completedTooltips.push(tooltipId);
// Store timestamp for this specific tooltip
if (!state.tooltipTimestamps) {
state.tooltipTimestamps = {};
}
state.tooltipTimestamps[tooltipId] = new Date().toISOString();
this._setStorage(state);
}
}
/**
* Check if the entire tour has been completed
* @returns {boolean} True if the tour is completed
*/
isTourCompleted() {
const state = this._getStorage();
if (!state) return false;
return state.tourCompleted === true;
}
/**
* Mark the entire tour as completed
*/
markTourCompleted() {
const state = this._getStorage();
if (!state) return;
state.tourCompleted = true;
state.completionTimestamp = new Date().toISOString();
this._setStorage(state);
}
/**
* Get the current step index
* @returns {number} The current step index (0-based)
*/
getCurrentStep() {
const state = this._getStorage();
if (!state) return 0;
return state.currentStep || 0;
}
/**
* Set the current step index
* @param {number} stepIndex - The step index to set (0-based)
*/
setCurrentStep(stepIndex) {
const state = this._getStorage();
if (!state) return;
state.currentStep = stepIndex;
this._setStorage(state);
}
/**
* Reset all progress and clear storage
*/
resetProgress() {
const defaultState = {
version: this.storageVersion,
tourCompleted: false,
completedTooltips: [],
currentStep: 0,
completionTimestamp: null,
dnsSetupDeferred: false,
lastVisit: new Date().toISOString()
};
this._setStorage(defaultState);
}
/**
* Get the completion timestamp
* @returns {Date|null} The completion timestamp or null if not completed
*/
getCompletionTimestamp() {
const state = this._getStorage();
if (!state || !state.completionTimestamp) return null;
return new Date(state.completionTimestamp);
}
/**
* Check if DNS setup was deferred
* @returns {boolean} True if DNS setup was deferred
*/
isDnsSetupDeferred() {
const state = this._getStorage();
if (!state) return false;
return state.dnsSetupDeferred === true;
}
/**
* Mark DNS setup as deferred
*/
markDnsSetupDeferred() {
const state = this._getStorage();
if (!state) return;
state.dnsSetupDeferred = true;
this._setStorage(state);
}
/**
* Get the timestamp for a specific tooltip completion
* @param {string} tooltipId - The ID of the tooltip
* @returns {Date|null} The timestamp or null if not completed
*/
getTooltipTimestamp(tooltipId) {
const state = this._getStorage();
if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) {
return null;
}
return new Date(state.tooltipTimestamps[tooltipId]);
}
/**
* Get all completed tooltip IDs
* @returns {string[]} Array of completed tooltip IDs
*/
getCompletedTooltips() {
const state = this._getStorage();
if (!state) return [];
return state.completedTooltips || [];
}
/**
* Get the last visit timestamp
* @returns {Date|null} The last visit timestamp
*/
getLastVisit() {
const state = this._getStorage();
if (!state || !state.lastVisit) return null;
return new Date(state.lastVisit);
}
}
// Export to global scope
window.ProgressTracker = ProgressTracker;
console.log('[ProgressTracker] Module loaded');
})(window);