mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge branch 'unstable' into fix-ui-tests-bugs-4073520511314692320
This commit is contained in:
14 files changed
+1117
-381
No files matched your search
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user