Merge branch 'unstable' into fix-ui-tests-bugs-4073520511314692320

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-20 07:36:43 -05:00
commit 1a2818f01f
14 files changed
+1117 -381

No files matched your search

+4 -3
View File
@@ -1,12 +1,13 @@
{
"name": "bitvid",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"postCreateCommand": "npm ci",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
"postCreateCommand": "npm ci && npm run build",
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint"
]
}
}
+6 -1
View File
@@ -56,7 +56,12 @@ To set up the project locally:
npm ci
```
2. **Run Tests**:
2. **Build**:
```bash
npm run build
```
3. **Run Tests**:
```bash
npm run test:unit
```
+6 -1
View File
@@ -107,7 +107,12 @@ To run **bitvid** locally:
npm ci
```
3. Start a local server:
3. Build the project:
```bash
npm run build
```
4. Start a local server:
- Using Python:
```bash
python -m http.server 8000
+1 -1
View File
@@ -11,4 +11,4 @@
--player-modal-primary-inline-padding-lg,clamp(var(--space-sm),2.75vw,var(--space-lg))
)}}.revert-modal__layout{display:grid;gap:1.5rem}@media (min-width:1024px){.revert-modal__layout{grid-template-columns:minmax(var(--revert-modal-sidebar-min),var(--revert-modal-sidebar-width)) minmax(0,1fr)}}#playerModal.video-modal__primary{display:block}.video-modal__primary{display:flex;flex-direction:column;gap:1.5rem;min-width:0;min-height:0}#playerModal.video-modal__secondary{display:block}.video-modal__secondary{display:flex;flex-direction:column;gap:1rem;min-width:0;min-height:0;width:100%;align-self:stretch}.video-modal__similar{display:none}@media (min-width:1024px){#playerModal.video-modal__similar{display:block}.video-modal__similar{display:flex;flex-direction:column;gap:1.5rem;align-self:stretch;flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module{height:100%;display:flex;flex:1 1 auto;min-height:0;flex-direction:column}#playerModal .video-modal__similar .player-modal__module-list{flex:1 1 auto;min-height:0}#playerModal .video-modal__similar .player-modal__module-scroll{height:100%;max-height:none;min-height:0}}#playerModal .player-modal__module-item{width:100%}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max))!important}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__media,#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__media{flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))!important}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.\!card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}#playerModal .video-modal__similar .player-modal__module-list{gap:clamp(var(--space-2xs),.85vw,var(--space-sm))}.player-modal__similar-card{outline:2px solid transparent;outline-offset:2px}.player-modal__similar-card:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:0.7;--tw-ring-offset-width:2px;--tw-ring-offset-color:var(--surface-base);transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-fast);transition-timing-function:cubic-bezier(0,0,.2,1);--tw-ring-color:var(--color-accent)}#playerModal.player-modal__similar-card{display:block}.player-modal__similar-card{position:relative;displayLine truncated
--motion-video-card-backdrop-translate
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max));flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-histoLine truncated
);--video-card-backdrop-scale:1.12;--video-card-backdrop-blur:var(--blur-video-card-backdrop-strong);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card:focus-visible{border-color:var(--color-accent);--tw-border-opacity:0.4;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=critical]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}.watch-history-card[data-state=private]{background-color:var(--surface-raised-private);--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg)}.watch-history-card[data-state=disabled],.watch-history-card[data-state=private]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card[data-state=disabled]{opacity:.6;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.watch-history-card[data-alert=nsfw-owner]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--color-black)}.watch-history-card[data-alert=nsfw-owner]:hover{--tw-border-opacity:0.6;--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.watch-history-card img[data-thumbnail-state=blurred],.watch-history-card img[data-visual-state=blurred]{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(.4,0,.2,1);filter:blur(var(--blur-thumbnail-blur));transform:scale(1.05)}.watch-history-card[data-owner-is-viewer=true][data-url-health-state=offline][data-stream-health-state=unhealthy]{border-width:1px;border-color:var(--color-critical);--tw-border-opacity:0.4;background-color:var(--surface-raised-critical)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card){display:flex;width:100%;flex-direction:row;flex-wrap:nowrap;align-items:stretch;gap:var(--space-md)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__media{width:clamp(var(--video-modal-item-media-min),18vw,var(--video-modal-item-media-max));flex-shrink:0;overflow:hidden;border-radius:var(--radius-lg)}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{padding:clamp(var(--space-sm),1.25vw,var(--space-md))}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__content{display:flex;min-width:0;flex:1 1 0%;flex-direction:column;gap:var(--space-sm)}#playerModal#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:block}#playerModal .player-modal__module-item>.watch-history-card:not(.player-modal__similar-card) .video-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;font-size:var(--font-size-xs);line-height:var(--line-height-tight);color:var(--text-muted-strong)}.watch-history-card[data-motion=enter]{opacity:0;animation:card-motion-enter .22s ease-out forwards}#playerModal.watch-history-card{display:block}.watch-history-card{display:flex;flex-wrap:wrap;align-items:stretch;gap:1.25rem;padding:1rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;transition-duration:var(--motion-duration-base);transition-timing-function:cubic-bezier(0,0,.2,1)}@media (prefers-reduced-motion:reduce){.watch-history-card,.watch-history-card[data-motion=enter]{transition-property:none}.watch-history-card[data-motion=enter]{opacity:1;animation:none}}.watch-history-card:hover{--tw-translate-y:-0.125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-shadow:var(--shadow-lg);--tw-shadow-colored:var(--shadow-lg);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.watch-history-card{gap:1.5rem;padding:1.25rem}}.watch-history-card--private{border-color:var(--color-info);--tw-border-opacity:0.6}#playerModal.watch-histoLine truncated
+2 -2
View File
@@ -5601,7 +5601,7 @@
.dm-composer__more-btn {
font-weight: 700;
line-height: 1;
min-width: 2rem;
min-width: var(--space-xl);
}
.dm-composer__more-menu {
@@ -5616,7 +5616,7 @@
z-index: 10;
display: flex;
flex-direction: column;
min-width: 8rem;
min-width: var(--size-8rem);
padding: var(--space-xs);
}
@@ -0,0 +1,32 @@
# Similar Content Logic Bug
This reproducer demonstrates bugs in the logic for calculating "Similar Content" candidates.
## The Bugs
The `computeSimilarContentCandidates` function in `js/app.js` (or related controller logic) has several issues identified by unit tests:
1. **Empty Results:** It seems to be returning empty results even when valid candidates exist.
2. **Tag Handling:** It may not be correctly identifying or counting shared tags.
3. **Filtering:** It fails to return public videos when mixed with NSFW/Private ones (possibly due to returning nothing at all).
## Running the Reproducer
```bash
node --import ./tests/test-helpers/setup-localstorage.mjs examples/reproducers/3-similar-content-logic-bug/reproduce_issue.js
```
## Expected Output (Failure)
```
Running Similar Content Logic Reproducer...
FAIL: orders similar videos by shared tags then timestamp
Expected 3 matches, got 0
FAIL: skips candidates without tag metadata
Expected 1 match, got 0
FAIL: filters NSFW and private videos when NSFW content is disabled
Expected ["video-public"], got []
Summary: FAIL
```
@@ -0,0 +1,241 @@
import Application from "../../../js/app.js";
import assert from "assert";
// Mock the method we want to test by grabbing it from the prototype
// Note: We use the prototype because the test file did so.
const computeSimilarContent = Application.prototype.computeSimilarContentCandidates;
function createAppStub({ videos = [] } = {}) {
const videosMap = new Map();
for (const video of videos) {
if (video && typeof video.id === "string") {
videosMap.set(video.id, video);
}
}
return {
videoListView: { currentVideos: videos },
videosMap,
getKnownVideoPostedAt(video) {
if (Number.isFinite(video?.rootCreatedAt)) {
return Math.floor(video.rootCreatedAt);
}
if (Number.isFinite(video?.created_at)) {
return Math.floor(video.created_at);
}
return null;
},
buildShareUrlFromEventId(id) {
return id ? `https://bitvid.invalid/${id}` : "";
},
formatTimeAgo(timestamp) {
return Number.isFinite(timestamp) ? `${timestamp}s` : "";
},
deriveVideoPointerInfo(video) {
if (video?.pointerInfo) {
return video.pointerInfo;
}
if (typeof video?.pointerKey === "string" && video.pointerKey.trim()) {
return { key: video.pointerKey.trim() };
}
return null;
},
isAuthorBlocked() {
return false;
},
};
}
function createVideo({
id,
pubkey = "author",
displayTags,
tags,
rootCreatedAt,
created_at,
pointerKey,
pointerInfo,
shareUrl,
isPrivate = false,
isNsfw = false,
} = {}) {
const video = {
id,
pubkey,
isPrivate,
isNsfw,
};
if (Array.isArray(displayTags)) {
video.displayTags = displayTags.slice();
}
if (Array.isArray(tags)) {
video.tags = tags.slice();
}
if (Number.isFinite(rootCreatedAt)) {
video.rootCreatedAt = Math.floor(rootCreatedAt);
}
if (Number.isFinite(created_at)) {
video.created_at = Math.floor(created_at);
}
if (typeof pointerKey === "string") {
video.pointerKey = pointerKey;
}
if (pointerInfo) {
video.pointerInfo = pointerInfo;
}
if (typeof shareUrl === "string") {
video.shareUrl = shareUrl;
}
return video;
}
function runTests() {
console.log("Running Similar Content Logic Reproducer...\n");
let failed = false;
// Test 1: Ordering by shared tags then timestamp
try {
const activeVideo = createVideo({
id: "root-video",
displayTags: ["Art", "Music", "News"],
});
const candidateA = createVideo({
id: "video-a",
displayTags: ["art", "music"],
rootCreatedAt: 100,
});
const candidateB = createVideo({
id: "video-b",
displayTags: ["art"],
rootCreatedAt: 200,
});
const candidateC = createVideo({
id: "video-c",
displayTags: ["art", "music"],
rootCreatedAt: 50,
});
const app = createAppStub({
videos: [candidateA, candidateB, candidateC],
});
const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 });
if (matches.length !== 3) {
throw new Error(`Expected 3 matches, got ${matches.length}`);
}
const ids = matches.map((entry) => entry.video.id);
const counts = matches.map((entry) => entry.sharedTagCount);
const expectedIds = ["video-a", "video-c", "video-b"];
const expectedCounts = [2, 2, 1];
if (JSON.stringify(ids) !== JSON.stringify(expectedIds)) {
throw new Error(`Order mismatch. Expected ${JSON.stringify(expectedIds)}, got ${JSON.stringify(ids)}`);
}
if (JSON.stringify(counts) !== JSON.stringify(expectedCounts)) {
throw new Error(`Counts mismatch. Expected ${JSON.stringify(expectedCounts)}, got ${JSON.stringify(counts)}`);
}
console.log("PASS: orders similar videos by shared tags then timestamp");
} catch (error) {
console.error("FAIL: orders similar videos by shared tags then timestamp");
console.error(" ", error.message);
failed = true;
}
// Test 2: Skips candidates without tag metadata
try {
const activeVideo = createVideo({
id: "root-video",
displayTags: ["art"],
});
const candidateWithTags = createVideo({
id: "video-a",
displayTags: ["art"],
});
const candidateWithoutTags = createVideo({
id: "video-b",
});
const app = createAppStub({
videos: [candidateWithTags, candidateWithoutTags],
});
const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 });
if (matches.length !== 1) {
throw new Error(`Expected 1 match, got ${matches.length}`);
}
if (matches[0].video.id !== "video-a") {
throw new Error(`Expected 'video-a', got '${matches[0]?.video?.id}'`);
}
console.log("PASS: skips candidates without tag metadata");
} catch (error) {
console.error("FAIL: skips candidates without tag metadata");
console.error(" ", error.message);
failed = true;
}
// Test 3: Filters NSFW and private videos
try {
const activeVideo = createVideo({
id: "root-video",
displayTags: ["art"],
});
const publicVideo = createVideo({
id: "video-public",
displayTags: ["art"],
});
const nsfwVideo = createVideo({
id: "video-nsfw",
displayTags: ["art"],
isNsfw: true,
});
const privateVideo = createVideo({
id: "video-private",
displayTags: ["art"],
isPrivate: true,
});
const app = createAppStub({
videos: [publicVideo, nsfwVideo, privateVideo],
});
const matches = computeSimilarContent.call(app, { activeVideo, maxItems: 5 });
const matchedIds = matches.map((entry) => entry.video.id);
const expectedIds = ["video-public"];
if (JSON.stringify(matchedIds) !== JSON.stringify(expectedIds)) {
throw new Error(`Expected ${JSON.stringify(expectedIds)}, got ${JSON.stringify(matchedIds)}`);
}
console.log("PASS: filters NSFW and private videos when NSFW content is disabled");
} catch (error) {
console.error("FAIL: filters NSFW and private videos when NSFW content is disabled");
console.error(" ", error.message);
failed = true;
}
if (failed) {
console.log("\nSummary: FAIL");
process.exit(1);
} else {
console.log("\nSummary: PASS");
}
}
runTests();
+1 -2
View File
@@ -157,7 +157,7 @@ export class AppShell {
);
const sidebar = createElement(doc, "aside", "dm-app-shell__sidebar");
const sidebarHeader = createElement(doc, "div", "dm-app-shell__sidebar-header flex items-center justify-between p-4");
const sidebarHeader = createElement(doc, "div", "dm-app-shell__sidebar-header flex items-center justify-between p-4 relative");
const sidebarTitle = createElement(
doc,
"h1",
@@ -184,7 +184,6 @@ export class AppShell {
onToggleTypingIndicators,
});
popover.classList.add("dm-settings-popover", "absolute", "z-50", "right-4", "top-12", "bg-surface", "border", "border-border", "rounded-xl");
sidebarHeader.classList.add("relative");
sidebarHeader.appendChild(popover);
const closeHandler = (event) => {
+249 -307
View File
@@ -1,325 +1,267 @@
import { chromium } from '@playwright/test';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Import environment setup FIRST to ensure polyfills are ready before other imports
import './setup-test-env.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '../../');
import { generateSecretKey, getPublicKey, nip19, nip04, nip44 } from 'nostr-tools';
import * as NostrTools from 'nostr-tools';
const ARTIFACTS_DIR = path.join(REPO_ROOT, 'artifacts');
const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-');
const LOG_FILE = path.join(ARTIFACTS_DIR, `interop-${TIMESTAMP}.log`);
const REPORT_FILE = path.join(ARTIFACTS_DIR, `interop-report-${TIMESTAMP}.json`);
const RELAY_PORT = 8008;
const HTTP_PORT = 8001;
// --- Imports from Codebase ---
// Relative path from scripts/agent/interop-test.mjs to js/nostr/client.js is ../../js/nostr/client.js
import { NostrClient } from '../../js/nostr/client.js';
import { decryptDM } from '../../js/dmDecryptor.js';
import {
buildVideoPostEvent,
buildViewEvent,
NOTE_TYPES
} from '../../js/nostrEventSchemas.js';
// Ensure artifacts dir exists
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
// --- Configuration ---
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol'
];
// Optional: Local relay if running
const LOCAL_RELAY = 'ws://localhost:8008';
// --- Helpers ---
function bytesToHex(bytes) {
return Buffer.from(bytes).toString('hex');
}
function log(message, type = 'INFO') {
const msg = `[${new Date().toISOString()}] [${type}] ${message}`;
console.log(msg);
fs.appendFileSync(LOG_FILE, msg + '\n');
async function checkRelay(url) {
return new Promise(resolve => {
try {
const ws = new WebSocket(url);
ws.on('open', () => { ws.close(); resolve(true); });
ws.on('error', () => { resolve(false); });
} catch { resolve(false); }
});
}
const stats = {
steps: 0,
failures: 0,
details: []
};
// --- Main Test ---
async function runTests() {
console.log("=== Starting Protocol & Interop Tests ===");
function recordStep(name, success, error = null) {
stats.steps++;
const detail = { name, success, timestamp: new Date().toISOString() };
if (!success) {
stats.failures++;
detail.error = error?.message || String(error);
log(`Step Failed: ${name} - ${detail.error}`, 'ERROR');
if (error?.stack) {
log(error.stack, 'ERROR');
}
// 1. Setup Relays
const activeRelays = [];
if (await checkRelay(LOCAL_RELAY)) {
console.log(`[Setup] Local relay found: ${LOCAL_RELAY}`);
activeRelays.push(LOCAL_RELAY);
} else {
console.log(`[Setup] Local relay not found. Using public test relays.`);
activeRelays.push(...RELAYS);
}
console.log(`[Setup] Active Relays:`, activeRelays);
// 2. Initialize Client
const client = new NostrClient();
client.relays = activeRelays;
client.readRelays = activeRelays;
client.writeRelays = activeRelays;
await client.init();
console.log("[Setup] NostrClient initialized.");
// 3. Generate Ephemeral Keys
const aliceSkBytes = generateSecretKey();
const aliceSk = bytesToHex(aliceSkBytes);
const alicePk = getPublicKey(aliceSkBytes);
console.log(`[Keys] Alice (Sender): ${alicePk}`);
const bobSkBytes = generateSecretKey();
const bobSk = bytesToHex(bobSkBytes);
const bobPk = getPublicKey(bobSkBytes);
console.log(`[Keys] Bob (Receiver): ${bobPk}`);
// Register Alice as signer
await client.registerPrivateKeySigner({ privateKey: aliceSk });
console.log("[Setup] Alice registered as active signer.");
// --- Test A: Video Post ---
console.log("\n>>> Test A: Publish VIDEO_POST (Kind 30078)");
const videoRootId = `test-${Date.now()}-${Math.random().toString(36).substring(2)}`;
const dTagValue = videoRootId;
const videoContent = {
version: 3,
title: `Interop Test ${Date.now()}`,
description: "Automated interop test with ephemeral keys.",
url: "https://example.com/video.mp4",
thumbnail: "https://example.com/thumb.jpg",
mimeType: "video/mp4",
isForKids: false,
isNsfw: false,
enableComments: false, // Guardrail: no session actor comments
videoRootId,
mode: "live"
};
// Explicitly using schema builder as per instructions
const videoEventTemplate = buildVideoPostEvent({
pubkey: alicePk,
created_at: Math.floor(Date.now() / 1000),
dTagValue,
content: videoContent
});
let videoEvent;
try {
const result = await client.signAndPublishEvent(videoEventTemplate);
videoEvent = result.signedEvent;
// Check acceptance. The structure of summary is { accepted: [...], failed: [...] }
const summary = result.summary;
if (!summary || !summary.accepted || summary.accepted.length === 0) {
throw new Error("Relays rejected the video event.");
}
console.log(`[Pass] Published Video Event ID: ${videoEvent.id}`);
} catch (e) {
console.error("[Fail] Video publish failed:", e);
process.exit(1);
}
// --- Test B: Fetch & Verify ---
console.log("\n>>> Test B: Fetch & Verify Roundtrip");
// Give relays a moment to index
await new Promise(r => setTimeout(r, 1500));
// Request raw event to verify protocol details
const result = await client.getEventById(videoEvent.id, { includeRaw: true });
if (!result || !result.rawEvent) {
console.error("[Fail] Could not fetch raw event back from relays.");
process.exit(1);
}
const { video, rawEvent } = result;
if (rawEvent.id !== videoEvent.id) {
console.error(`[Fail] ID mismatch. Expected ${videoEvent.id}, got ${rawEvent.id}`);
process.exit(1);
}
// Verify shape by parsing raw content
let content;
try {
content = JSON.parse(rawEvent.content);
} catch {
content = {};
}
if (content.title !== videoContent.title) {
console.error(`[Fail] Content mismatch in raw event. Title: ${content.title} vs ${videoContent.title}`);
process.exit(1);
}
if (video.title !== videoContent.title) {
console.error(`[Fail] Content mismatch in normalized video object. Title: ${video.title}`);
process.exit(1);
}
console.log("[Pass] Event fetched and verified successfully (Raw + Normalized).");
// --- Test C: View Event ---
console.log("\n>>> Test C: Publish VIEW_EVENT");
// Explicitly using schema builder as per instructions
// View event points to the video
const viewEventTemplate = buildViewEvent({
pubkey: alicePk,
created_at: Math.floor(Date.now() / 1000),
pointerValue: videoEvent.id,
pointerTag: ['e', videoEvent.id],
dedupeTag: `view:${videoEvent.id}:${Date.now()}`,
includeSessionTag: false // Not a session actor
});
try {
const viewResult = await client.signAndPublishEvent(viewEventTemplate);
const viewSummary = viewResult.summary;
if (viewSummary && viewSummary.accepted && viewSummary.accepted.length > 0) {
console.log(`[Pass] View Event published. ID: ${viewResult.signedEvent.id}`);
} else {
log(`Step Passed: ${name}`, 'SUCCESS');
console.warn(`[Warn] View Event publish result not OK:`, viewResult);
}
stats.details.push(detail);
}
} catch (e) {
console.error("[Fail] View Event publish threw:", e);
}
async function startProcess(command, args, cwd, readyCheck) {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { cwd, stdio: 'pipe' });
let started = false;
const label = args.some(a => a.includes('relay')) ? 'RELAY' : command;
// --- Test D: Encrypted DM ---
console.log("\n>>> Test D: Direct Message (Encryption/Decryption)");
const dmMessage = `Secret message ${Date.now()}`;
const checkData = (data) => {
const output = data.toString();
if (started) {
if (label === 'RELAY') log(`[RELAY] ${output.trim()}`, 'DEBUG');
return;
}
if (readyCheck(output)) {
started = true;
resolve(proc);
}
};
console.log(`[DM] Sending from Alice to Bob...`);
try {
const bobNpub = nip19.npubEncode(bobPk);
const realSendRes = await client.sendDirectMessage(bobNpub, dmMessage);
if (!realSendRes.ok) {
throw new Error(realSendRes.error || "Unknown error");
}
console.log("[DM] Message sent.");
} catch (e) {
console.error("[Fail] DM Send failed:", e);
process.exit(1);
}
proc.stdout.on('data', (data) => {
checkData(data);
});
proc.stderr.on('data', (data) => {
checkData(data);
});
// Verify by fetching as Bob
await new Promise(r => setTimeout(r, 2000));
proc.on('error', (err) => reject(err));
proc.on('exit', (code) => {
if (!started) reject(new Error(`${command} exited early with code ${code}`));
});
console.log(`[DM] Fetching for Bob...`);
const filter = {
kinds: [4, 1059], // Legacy and GiftWrap
'#p': [bobPk],
authors: [alicePk] // Sent by Alice
};
// Timeout fallback
setTimeout(() => {
if (!started) {
// For http server, sometimes it doesn't output much
if (command.includes('python') || command.includes('http.server')) {
started = true;
resolve(proc);
} else {
reject(new Error(`${command} timed out waiting for ready signal`));
}
}
}, 3000);
const dmEvents = await client.pool.list(activeRelays, [filter]);
console.log(`[DM] Found ${dmEvents.length} events for Bob.`);
if (dmEvents.length === 0) {
console.error("[Fail] No DM events found on relays.");
process.exit(1);
}
const targetEvent = dmEvents[0];
console.log(`[DM] Decrypting event kind ${targetEvent.kind}...`);
const bobDecryptors = [];
if (nip44) {
bobDecryptors.push({
scheme: 'nip44',
decrypt: (pk, ct) => nip44.decrypt(bobSk, pk, ct),
priority: 1,
supportsGiftWrap: true
});
}
if (nip04) {
bobDecryptors.push({
scheme: 'nip04',
decrypt: async (pk, ct) => nip04.decrypt(bobSk, pk, ct),
priority: 0
});
}
const decryptContext = {
actorPubkey: bobPk,
decryptors: bobDecryptors
};
const decryptRes = await decryptDM(targetEvent, decryptContext);
if (!decryptRes.ok) {
console.error("[Fail] Decryption failed:", decryptRes.errors);
process.exit(1);
}
if (decryptRes.plaintext !== dmMessage) {
console.error(`[Fail] Message mismatch. Got: "${decryptRes.plaintext}", Expected: "${dmMessage}"`);
process.exit(1);
}
console.log(`[Pass] Decryption verified: "${decryptRes.plaintext}"`);
console.log("\n=== All Tests Passed ===");
process.exit(0);
}
async function runInteropTest() {
let relayProc, serverProc, browser;
try {
log('Starting interop test...');
// 1. Start Relay
log('Starting local relay...');
relayProc = await startProcess(
'node',
['scripts/agent/simple-relay.mjs'],
REPO_ROOT,
(output) => output.includes(`running on ws://localhost:${RELAY_PORT}`) || output.includes(`running on port ${RELAY_PORT}`)
);
} catch (e) {
log('Failed to start relay: ' + e.message, 'FATAL');
}
try {
// 2. Start HTTP Server
log('Starting HTTP server...');
serverProc = await startProcess(
'python3',
['-m', 'http.server', String(HTTP_PORT)],
REPO_ROOT,
(output) => output.includes(`Serving HTTP on 0.0.0.0 port ${HTTP_PORT}`)
);
// 3. Launch Browser
log('Launching browser...');
browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
// 4. Navigate to app
log(`Navigating to http://localhost:${HTTP_PORT}...`);
await page.goto(`http://localhost:${HTTP_PORT}`, { waitUntil: 'networkidle' });
// 5. Inject Test Logic
log('Executing test logic in browser...');
const testResult = await page.evaluate(async ({ relayUrl }) => {
const logs = [];
const steps = [];
function log(msg) { console.log(msg); logs.push(msg); }
try {
// Import dependencies
const { nostrClient } = await import('./js/nostr/defaultClient.js');
const schemas = await import('./js/nostrEventSchemas.js');
// Wait for NostrTools
if (!window.NostrTools) {
await new Promise(r => setTimeout(r, 1000));
if (!window.NostrTools) throw new Error("NostrTools not available on window");
}
const NostrTools = window.NostrTools;
// --- Step 1: Init ---
nostrClient.relays = [relayUrl];
nostrClient.readRelays = [relayUrl];
nostrClient.writeRelays = [relayUrl];
await nostrClient.init();
await nostrClient.connectToRelays();
steps.push({ name: 'Init & Connect', success: true });
// --- Step 2: Setup Alice (Video Publisher) ---
const skAlice = NostrTools.generateSecretKey();
const pkAlice = NostrTools.getPublicKey(skAlice);
const hexSkAlice = NostrTools.utils.bytesToHex(skAlice);
await nostrClient.registerPrivateKeySigner({ privateKey: hexSkAlice, pubkey: pkAlice });
steps.push({ name: 'Setup Alice', success: true });
// --- Test A: Publish VIDEO_POST ---
const dTagValue = `test-video-${Date.now()}`;
const videoContent = {
version: 3,
title: 'Interop Test Video',
description: 'Testing event schemas',
videoRootId: `root-${dTagValue}`,
mode: 'dev',
url: 'https://example.com/video.mp4',
magnet: 'magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&dn=video.mp4'
};
const videoEventSkeleton = schemas.buildVideoPostEvent({
pubkey: pkAlice,
created_at: Math.floor(Date.now() / 1000),
dTagValue: dTagValue,
content: videoContent
});
// Use signAndPublishEvent to sign and publish
const { signedEvent: publishedVideo } = await nostrClient.signAndPublishEvent(videoEventSkeleton);
if (!publishedVideo || !publishedVideo.id || !publishedVideo.sig) {
throw new Error("Video publish returned incomplete event");
}
steps.push({ name: 'Publish VIDEO_POST', success: true });
// --- Test B: Fetch and Validate Video ---
await new Promise(r => setTimeout(r, 500)); // wait for relay
const fetchedVideo = await nostrClient.getEventById(publishedVideo.id, { includeRaw: true });
if (!fetchedVideo) throw new Error("Failed to fetch video event back");
// Validate shape
const fetchedRaw = fetchedVideo.rawEvent || fetchedVideo; // handle wrapper
if (fetchedRaw.id !== publishedVideo.id) throw new Error("ID mismatch");
if (fetchedRaw.pubkey !== pkAlice) throw new Error("Pubkey mismatch");
// Parse content
const fetchedContent = typeof fetchedRaw.content === 'string' ? JSON.parse(fetchedRaw.content) : fetchedRaw.content;
if (fetchedContent.title !== videoContent.title) throw new Error("Content title mismatch");
if (fetchedContent.videoRootId !== videoContent.videoRootId) throw new Error("Content rootId mismatch");
steps.push({ name: 'Verify VIDEO_POST Roundtrip', success: true });
// --- Test C: Publish VIEW_EVENT ---
const viewEventSkeleton = schemas.buildViewEvent({
pubkey: pkAlice,
created_at: Math.floor(Date.now() / 1000),
pointerTag: ['e', publishedVideo.id], // pointing to the video
content: 'test view'
});
const { signedEvent: publishedView } = await nostrClient.signAndPublishEvent(viewEventSkeleton);
await new Promise(r => setTimeout(r, 500));
const fetchedView = await nostrClient.fetchRawEventById(publishedView.id);
if (!fetchedView) throw new Error("Failed to fetch view event back");
steps.push({ name: 'Publish & Verify VIEW_EVENT', success: true });
// --- Test D: Encrypted DM (Alice -> Bob) ---
const skBob = NostrTools.generateSecretKey();
const pkBob = NostrTools.getPublicKey(skBob);
const hexSkBob = NostrTools.utils.bytesToHex(skBob);
const npubBob = NostrTools.nip19.npubEncode(pkBob);
// Alice is currently logged in
const dmMessage = `Hello Bob ${Date.now()}`;
const dmResult = await nostrClient.sendDirectMessage(npubBob, dmMessage);
if (!dmResult.ok) throw new Error("Failed to send DM: " + dmResult.error);
steps.push({ name: 'Send Encrypted DM', success: true });
// Switch to Bob to decrypt
nostrClient.logout();
await nostrClient.registerPrivateKeySigner({ privateKey: hexSkBob, pubkey: pkBob });
await new Promise(r => setTimeout(r, 500));
const dms = await nostrClient.listDirectMessages(pkBob);
const receivedDm = dms.find(m => m.plaintext === dmMessage);
if (!receivedDm) {
throw new Error(`Bob could not find/decrypt the DM. Found: ${dms.length} messages.`);
}
if (receivedDm.sender.pubkey !== pkAlice) {
throw new Error("DM sender mismatch");
}
steps.push({ name: 'Decrypt DM', success: true });
return { success: true, steps, logs };
} catch (e) {
return { success: false, steps, logs, error: e.message, stack: e.stack };
}
}, { relayUrl: `ws://localhost:${RELAY_PORT}` });
// Process results
if (testResult.logs) {
testResult.logs.forEach(l => log(`[Browser] ${l}`));
}
if (testResult.steps) {
testResult.steps.forEach(s => recordStep(s.name, s.success));
}
if (!testResult.success) {
throw new Error(testResult.error || "Unknown browser test failure");
}
} catch (err) {
log(`Test failed: ${err.message}`, 'FATAL');
recordStep('Interop Test Suite', false, err);
// Take screenshot if browser is active
if (browser) {
try {
const page = browser.contexts()[0].pages()[0];
if (page) {
await page.screenshot({ path: path.join(ARTIFACTS_DIR, `interop-failure-${TIMESTAMP}.png`) });
log(`Screenshot saved to interop-failure-${TIMESTAMP}.png`);
}
} catch (e) {
log('Failed to capture screenshot: ' + e.message);
}
}
} finally {
// Cleanup
if (browser) await browser.close();
if (serverProc) {
try { process.kill(serverProc.pid); } catch (e) { /* ignore */ }
}
if (relayProc) {
try { process.kill(relayProc.pid); } catch (e) { /* ignore */ }
}
// Report
fs.writeFileSync(REPORT_FILE, JSON.stringify({
timestamp: new Date().toISOString(),
stats,
logFile: LOG_FILE
}, null, 2));
console.log(`Report saved to ${REPORT_FILE}`);
if (stats.failures > 0) process.exit(1);
}
}
runInteropTest();
runTests().catch(e => {
console.error("Unhandled Error:", e);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
import WebSocket from 'ws';
import { webcrypto } from 'node:crypto';
// Polyfill WebSocket
if (typeof globalThis.WebSocket === 'undefined') {
globalThis.WebSocket = WebSocket;
}
// Polyfill window and self
if (typeof globalThis.window === 'undefined') {
globalThis.window = globalThis;
}
if (typeof globalThis.self === 'undefined') {
globalThis.self = globalThis;
}
// Polyfill crypto
if (typeof globalThis.crypto === 'undefined') {
globalThis.crypto = webcrypto;
}
// Polyfill localStorage
if (typeof globalThis.localStorage === 'undefined') {
const storage = new Map();
globalThis.localStorage = {
getItem: (k) => storage.get(k) || null,
setItem: (k, v) => storage.set(String(k), String(v)),
removeItem: (k) => storage.delete(k),
clear: () => storage.clear(),
key: (i) => Array.from(storage.keys())[i] || null,
get length() { return storage.size; }
};
}
if (typeof globalThis.window.localStorage === 'undefined') {
globalThis.window.localStorage = globalThis.localStorage;
}
// Mock navigator
if (typeof globalThis.navigator === 'undefined') {
globalThis.navigator = { userAgent: 'node' };
}
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Navigate up from scripts/agent/ to root
const ROOT_DIR = path.resolve(__dirname, '../../');
const ARTIFACTS_DIR = path.join(ROOT_DIR, 'artifacts');
const REPORTS_DIR = path.join(ROOT_DIR, 'ai/reports');
const AGGREGATE_FILE = path.join(ARTIFACTS_DIR, 'error-aggregates.json');
// Ensure reports dir exists
if (!fs.existsSync(REPORTS_DIR)) {
fs.mkdirSync(REPORTS_DIR, { recursive: true });
}
/**
* Strips PII from text.
* @param {string} text
* @returns {string}
*/
function sanitize(text) {
if (!text) return '';
// Emails
text = text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]');
// IPv4 - simple matching
text = text.replace(/\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g, '[IP]');
// Nostr Bech32 Keys (nsec, npub, etc.)
text = text.replace(/\b(nsec|npub|nprofile|nevent|naddr|nrelay|ncryptsec)1[a-z0-9]+\b/g, '[KEY]');
// Hex Keys (64 chars) - e.g. private keys or signatures
text = text.replace(/\b[a-f0-9]{64}\b/g, '[HEX_KEY]');
return text;
}
/**
* Generates a fingerprint for the error based on stack trace.
* @param {string} stack
* @returns {string}
*/
function getFingerprint(stack) {
const hash = crypto.createHash('sha256');
hash.update(stack);
return hash.digest('hex').substring(0, 8);
}
/**
* Suggests an owner based on the stack trace content.
* @param {string} stack
* @returns {string}
*/
function suggestOwner(stack) {
const lowerStack = stack.toLowerCase();
if (lowerStack.includes('js/nostr') || lowerStack.includes('nip')) return 'Protocol Team';
if (lowerStack.includes('js/ui') || lowerStack.includes('components') || lowerStack.includes('css')) return 'Frontend Team';
if (lowerStack.includes('auth') || lowerStack.includes('crypto')) return 'Security Team';
if (lowerStack.includes('tests') || lowerStack.includes('spec')) return 'QA Team';
if (lowerStack.includes('storage') || lowerStack.includes('db')) return 'Storage Team';
if (lowerStack.includes('agent') || lowerStack.includes('scripts')) return 'DevOps/Agent Team';
return 'Unassigned';
}
/**
* Parses log content into a list of error objects.
* @param {string} content
* @param {string} filename
* @returns {Array}
*/
function parseLogFile(content, filename) {
const errors = [];
const lines = content.split('\n');
let currentError = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Heuristics for error detection
const isStackLine = /^\s+at /.test(line);
// Detect "Error:", "Exception", or "FAIL" (test output)
const isErrorStart = (/Error:|Exception|FAIL/.test(line)) && !isStackLine;
if (isErrorStart) {
if (currentError) {
errors.push(currentError);
}
currentError = {
message: line.trim(),
stack: [line.trim()],
file: filename
};
} else if (isStackLine && currentError) {
currentError.stack.push(line.trim());
} else if (currentError && line.trim() !== '') {
// If we see a non-empty line:
// If it looks like a timestamped log line "[2023...]", it's probably a new log entry, so close the error.
if (/^\[.*?\]/.test(line)) {
errors.push(currentError);
currentError = null;
// If this line itself is an error start, we'll catch it in the next iteration
// effectively, but since we are iterating line by line, we need to handle it.
// Actually, if this line IS an error start, the `isErrorStart` check would have caught it if we structure the loop right.
// But `isErrorStart` was checked at the top.
// So if we are "in an error" and see a timestamp, we close the error.
// But wait, what if the timestamp line IS the error line?
// e.g. [2023...] ERROR: something
// In that case `isErrorStart` would be true (if it contains Error:).
// So this block handles lines that are NOT `isErrorStart` but ARE part of the previous error.
// If it's a timestamp line and NOT an error start, it's just a regular log, so close the previous error.
} else {
// Just text, assume it's part of the error message/stack unless we already had stack frames and this stopped being one.
const hasStackFrames = currentError.stack.some(l => l.startsWith('at '));
if (hasStackFrames && !isStackLine) {
// We had stack frames, and now we don't. Probably end of error.
errors.push(currentError);
currentError = null;
} else {
// Append to message/stack
currentError.stack.push(line.trim());
if (!hasStackFrames) {
currentError.message += ' ' + line.trim();
}
}
}
}
}
if (currentError) {
errors.push(currentError);
}
return errors;
}
function main() {
if (!fs.existsSync(ARTIFACTS_DIR)) {
console.log(`Artifacts directory not found at ${ARTIFACTS_DIR}`);
return;
}
const aggregates = {};
const files = fs.readdirSync(ARTIFACTS_DIR);
console.log(`Scanning ${files.length} files in ${ARTIFACTS_DIR}...`);
for (const file of files) {
if (file.endsWith('.json') && file !== 'error-aggregates.json') {
// TODO: Handle JSON logs if schema is known. For now, skipping or treating as text if needed.
// If it's the load report, we might want to skip or parse differently.
continue;
}
if (file === 'error-aggregates.json') continue;
// Skip directories
const filePath = path.join(ARTIFACTS_DIR, file);
if (fs.statSync(filePath).isDirectory()) continue;
console.log(`Processing ${file}...`);
const content = fs.readFileSync(filePath, 'utf8');
const errors = parseLogFile(content, file);
for (const err of errors) {
const sanitizedStack = sanitize(err.stack.join('\n'));
const sanitizedMsg = sanitize(err.message);
const fingerprint = getFingerprint(sanitizedStack);
if (!aggregates[fingerprint]) {
aggregates[fingerprint] = {
fingerprint,
message: sanitizedMsg,
stack: sanitizedStack,
count: 0,
sources: new Set(),
owner: suggestOwner(sanitizedStack)
};
}
aggregates[fingerprint].count++;
aggregates[fingerprint].sources.add(file);
}
}
// Convert Set to Array for JSON
const resultList = Object.values(aggregates).map(a => ({
...a,
sources: Array.from(a.sources)
})).sort((a, b) => b.count - a.count);
console.log(`Found ${resultList.length} unique errors.`);
// Save JSON
fs.writeFileSync(AGGREGATE_FILE, JSON.stringify(resultList, null, 2));
console.log(`Saved aggregate data to ${AGGREGATE_FILE}`);
// Generate Markdown
const dateStr = new Date().toISOString().split('T')[0].replace(/-/g, '');
const reportPath = path.join(REPORTS_DIR, `telemetry-${dateStr}.md`);
let mdContent = `# Telemetry Report ${dateStr}\n\n`;
mdContent += `**Total Unique Errors:** ${resultList.length}\n`;
mdContent += `**Total Occurrences:** ${resultList.reduce((sum, i) => sum + i.count, 0)}\n\n`;
mdContent += `## Top Errors\n\n`;
resultList.slice(0, 10).forEach((err, idx) => {
mdContent += `### ${idx + 1}. ${err.message.substring(0, 100)}${err.message.length > 100 ? '...' : ''}\n\n`;
mdContent += `- **Count:** ${err.count}\n`;
mdContent += `- **Suggested Owner:** ${err.owner}\n`;
mdContent += `- **Fingerprint:** \`${err.fingerprint}\`\n`;
mdContent += `- **Sources:** ${err.sources.join(', ')}\n`;
mdContent += `\n\`\`\`text\n${err.stack}\n\`\`\`\n\n`;
});
fs.writeFileSync(reportPath, mdContent);
console.log(`Report generated at ${reportPath}`);
}
main();
+113 -64
View File
@@ -1,9 +1,12 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import "../../tests/test-helpers/setup-localstorage.mjs"; // Ensure localStorage/window mocks
import SimilarContentController from "../../js/ui/similarContentController.js";
import { ALLOW_NSFW_CONTENT } from "../../js/config.js";
import SimilarContentController from "../../js/ui/similarContentController.js";
function createAppStub({ videos = [] } = {}) {
function createControllerStub({ videos = [] } = {}) {
const videosMap = new Map();
for (const video of videos) {
if (video && typeof video.id === "string") {
@@ -11,9 +14,18 @@ function createAppStub({ videos = [] } = {}) {
}
}
const appStub = {
videoListView: { currentVideos: videos },
videosMap,
const services = {
nostrClient: {
getActiveVideos: () => videos,
},
};
const state = {
getVideoListView: () => ({ currentVideos: videos }),
getVideosMap: () => videosMap,
};
const helpers = {
getKnownVideoPostedAt(video) {
if (Number.isFinite(video?.rootCreatedAt)) {
return Math.floor(video.rootCreatedAt);
@@ -29,48 +41,18 @@ function createAppStub({ videos = [] } = {}) {
formatTimeAgo(timestamp) {
return Number.isFinite(timestamp) ? `${timestamp}s` : "";
},
deriveVideoPointerInfo(video) {
if (video?.pointerInfo) {
return video.pointerInfo;
}
if (typeof video?.pointerKey === "string" && video.pointerKey.trim()) {
return { key: video.pointerKey.trim() };
}
return null;
},
isAuthorBlocked() {
return false;
},
};
const controller = new SimilarContentController({
services: {
nostrClient: {
getActiveVideos: () => videos,
},
},
callbacks: {
isAuthorBlocked: (pubkey) => appStub.isAuthorBlocked(pubkey),
decorateVideoModeration: (video) => video,
decorateVideoCreatorIdentity: (video) => video,
},
state: {
getVideoListView: () => appStub.videoListView,
getVideosMap: () => appStub.videosMap,
},
helpers: {
getKnownVideoPostedAt: (video) => appStub.getKnownVideoPostedAt(video),
buildShareUrlFromEventId: (id) => appStub.buildShareUrlFromEventId(id),
formatTimeAgo: (ts) => appStub.formatTimeAgo(ts),
},
const callbacks = {
isAuthorBlocked: () => false,
};
return new SimilarContentController({
services,
state,
helpers,
callbacks,
});
appStub.similarContentController = controller;
appStub.computeSimilarContentCandidates = function (options) {
return this.similarContentController.computeCandidates(options);
};
return appStub;
}
function createVideo({
@@ -118,6 +100,65 @@ function createVideo({
return video;
}
function createController({ videos = [] } = {}) {
const videosMap = new Map();
for (const video of videos) {
if (video && typeof video.id === "string") {
videosMap.set(video.id, video);
}
}
const state = {
getVideoListView: () => ({ currentVideos: videos }),
getVideosMap: () => videosMap,
};
const services = {
nostrClient: {
getActiveVideos: () => videos,
},
};
const callbacks = {
decorateVideoModeration: (v) => v,
decorateVideoCreatorIdentity: (v) => v,
isAuthorBlocked: () => false,
};
const helpers = {
getKnownVideoPostedAt(video) {
if (Number.isFinite(video?.rootCreatedAt)) {
return Math.floor(video.rootCreatedAt);
}
if (Number.isFinite(video?.created_at)) {
return Math.floor(video.created_at);
}
return null;
},
buildShareUrlFromEventId(id) {
return id ? `https://bitvid.invalid/${id}` : "";
},
formatTimeAgo(timestamp) {
return Number.isFinite(timestamp) ? `${timestamp}s` : "";
},
};
const ui = {
videoModal: {
setSimilarContent: () => {},
clearSimilarContent: () => {},
},
};
return new SimilarContentController({
state,
services,
callbacks,
helpers,
ui,
});
}
test("orders similar videos by shared tags then timestamp", () => {
const activeVideo = createVideo({
id: "root-video",
@@ -140,15 +181,19 @@ test("orders similar videos by shared tags then timestamp", () => {
rootCreatedAt: 50,
});
const app = createAppStub({
const controller = createControllerStub({
videos: [candidateA, candidateB, candidateC],
});
const matches = app.computeSimilarContentCandidates({
activeVideo,
maxItems: 5,
});
const matches = controller.computeCandidates({ activeVideo, maxItems: 5 });
assert.equal(matches.length, 3);
// A: 2 shared tags, 100s
// C: 2 shared tags, 50s
// B: 1 shared tag, 200s
// Sort by sharedTagCount desc, then postedAt desc.
// A (2, 100) > C (2, 50) > B (1, 200)
assert.deepEqual(
matches.map((entry) => entry.video.id),
["video-a", "video-c", "video-b"],
@@ -169,11 +214,8 @@ test("returns no matches when the active video lacks tags", () => {
displayTags: ["art"],
});
const app = createAppStub({ videos: [candidate] });
const matches = app.computeSimilarContentCandidates({
activeVideo,
maxItems: 5,
});
const controller = createControllerStub({ videos: [candidate] });
const matches = controller.computeCandidates({ activeVideo, maxItems: 5 });
assert.deepEqual(matches, []);
});
@@ -191,19 +233,29 @@ test("skips candidates without tag metadata", () => {
id: "video-b",
});
const app = createAppStub({
const controller = createControllerStub({
videos: [candidateWithTags, candidateWithoutTags],
});
const matches = app.computeSimilarContentCandidates({
activeVideo,
maxItems: 5,
});
const matches = controller.computeCandidates({ activeVideo, maxItems: 5 });
assert.equal(matches.length, 1);
assert.equal(matches[0].video.id, "video-a");
});
test("filters NSFW and private videos when NSFW content is disabled", () => {
test("filters NSFW and private videos when NSFW content is disabled", (t) => {
// We can't easily mock ALLOW_NSFW_CONTENT from config.js directly if it's a const export.
// However, the controller imports it. If we can't mock the module, we might fail this test if the default is true/false.
// js/config.js exports ALLOW_NSFW_CONTENT.
// Assuming default is likely false or true.
// If the test depends on the config value, we might need to skip this test or mock the module import.
// Since we are in node:test, module mocking is tricky without a loader or a DI approach.
// NOTE: In the original test, `ALLOW_NSFW_CONTENT` was imported by `Application` (via `js/app.js` -> `js/config.js`).
// The controller also imports it.
// Let's assume the default `ALLOW_NSFW_CONTENT` allows us to test filtering (i.e. if it's false).
// If it's true, filtering won't happen.
// We can try to rely on `isPrivate` at least.
const activeVideo = createVideo({
id: "root-video",
displayTags: ["art"],
@@ -224,14 +276,11 @@ test("filters NSFW and private videos when NSFW content is disabled", () => {
isPrivate: true,
});
const app = createAppStub({
const controller = createControllerStub({
videos: [publicVideo, nsfwVideo, privateVideo],
});
const matches = app.computeSimilarContentCandidates({
activeVideo,
maxItems: 5,
});
const matches = controller.computeCandidates({ activeVideo, maxItems: 5 });
assert.deepEqual(
matches.map((entry) => entry.video.id),
["video-public"],
+103
View File
@@ -0,0 +1,103 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import * as sessionActor from "../../js/nostr/sessionActor.js";
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
// Ensure localStorage is available
import "../test-helpers/setup-localstorage.mjs";
// Polyfill IndexedDB
globalThis.indexedDB = indexedDB;
globalThis.IDBKeyRange = IDBKeyRange;
test("SessionActor", async (t) => {
const passphrase = "correct-horse-battery-staple";
const privateKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
await t.test("encrypts and decrypts private key", async () => {
const encrypted = await sessionActor.encryptSessionPrivateKey(privateKey, passphrase);
assert.ok(encrypted.ciphertext);
assert.ok(encrypted.salt);
assert.ok(encrypted.iv);
assert.equal(encrypted.algorithm, "AES-GCM");
const payload = {
privateKeyEncrypted: encrypted.ciphertext,
encryption: {
salt: encrypted.salt,
iv: encrypted.iv,
iterations: encrypted.iterations,
hash: encrypted.hash,
version: encrypted.version,
algorithm: encrypted.algorithm,
}
};
const decrypted = await sessionActor.decryptSessionPrivateKey(payload, passphrase);
assert.equal(decrypted, privateKey);
});
await t.test("fails to decrypt with wrong passphrase", async () => {
const encrypted = await sessionActor.encryptSessionPrivateKey(privateKey, passphrase);
const payload = {
privateKeyEncrypted: encrypted.ciphertext,
encryption: {
salt: encrypted.salt,
iv: encrypted.iv,
}
};
try {
await sessionActor.decryptSessionPrivateKey(payload, "wrong-password");
assert.fail("Should have thrown error");
} catch (error) {
assert.match(error.message, /Failed to decrypt/);
}
});
await t.test("persists and reads session actor", async () => {
// Clean up first
localStorage.clear();
sessionActor.clearStoredSessionActor();
const actor = {
pubkey: "pubkey123",
privateKeyEncrypted: "encrypteddata",
encryption: {
salt: "salt",
iv: "iv",
iterations: 1000,
},
createdAt: Date.now(),
};
sessionActor.persistSessionActor(actor);
// Verify localStorage
const stored = JSON.parse(localStorage.getItem(sessionActor.SESSION_ACTOR_STORAGE_KEY));
assert.equal(stored.pubkey, actor.pubkey);
assert.equal(stored.privateKeyEncrypted, actor.privateKeyEncrypted);
// Verify read
const read = sessionActor.readStoredSessionActorEntry();
assert.ok(read);
assert.equal(read.pubkey, actor.pubkey);
assert.equal(read.privateKeyEncrypted, actor.privateKeyEncrypted);
assert.equal(read.encryption.salt, actor.encryption.salt);
});
await t.test("clears stored session actor", async () => {
const actor = {
pubkey: "pubkey123",
privateKeyEncrypted: "encrypteddata",
encryption: { salt: "salt", iv: "iv" }
};
sessionActor.persistSessionActor(actor);
sessionActor.clearStoredSessionActor();
const read = sessionActor.readStoredSessionActorEntry();
assert.equal(read, null);
assert.equal(localStorage.getItem(sessionActor.SESSION_ACTOR_STORAGE_KEY), null);
});
});
@@ -0,0 +1,88 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { LinkPreviewService } from "../../js/services/linkPreviewService.js";
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
import { JSDOM } from "jsdom";
// Polyfill IndexedDB
globalThis.indexedDB = indexedDB;
globalThis.IDBKeyRange = IDBKeyRange;
// Polyfill DOMParser
const dom = new JSDOM();
globalThis.DOMParser = dom.window.DOMParser;
globalThis.URL = URL;
test("LinkPreviewService", async (t) => {
const service = new LinkPreviewService({ ttlMs: 1000 });
await t.test("initializes IndexedDB", async () => {
const db = await service.init();
assert.ok(db);
assert.equal(db.objectStoreNames.contains("previews"), true);
});
await t.test("fetches and caches preview", async () => {
// Mock fetch
const htmlContent = `
<html>
<head>
<meta property="og:title" content="Test Title" />
<meta property="og:description" content="Test Description" />
<meta property="og:image" content="https://example.com/image.png" />
<meta property="og:site_name" content="Test Site" />
</head>
</html>
`;
globalThis.fetch = async (url) => {
if (url === "https://example.com") {
return {
ok: true,
text: async () => htmlContent,
};
}
throw new Error("Network error");
};
const preview = await service.getPreview("https://example.com");
assert.ok(preview);
assert.equal(preview.title, "Test Title");
assert.equal(preview.description, "Test Description");
assert.equal(preview.image, "https://example.com/image.png");
assert.equal(preview.siteName, "Test Site");
// Verify cache
const cached = await service.getCachedPreview("https://example.com");
assert.deepEqual(cached, preview);
});
await t.test("returns null on fetch failure", async () => {
globalThis.fetch = async () => {
throw new Error("Network failure");
};
const preview = await service.getPreview("https://fail.com");
assert.equal(preview, null);
});
await t.test("respects TTL", async () => {
// Set a preview that is expired
const expiredService = new LinkPreviewService({ ttlMs: -1000 });
// Initialize DB first to ensure we write to the same place
await expiredService.init();
await expiredService.setCachedPreview("https://expired.com", { title: "Expired" });
const cached = await expiredService.getCachedPreview("https://expired.com");
assert.equal(cached, null);
});
await t.test("deletePreview removes from cache", async () => {
await service.setCachedPreview("https://delete.com", { title: "Delete Me" });
await service.deletePreview("https://delete.com");
const cached = await service.getCachedPreview("https://delete.com");
assert.equal(cached, null);
});
});