mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 00:42:30 +00:00
Merge branch 'unstable' into fix-webtorrent-webseed-regression-10274894627332192935
This commit is contained in:
28 files changed
+874
-719
No files matched your search
@@ -1 +1,8 @@
|
||||
js/ui/profileModalController.js:204: "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
|
||||
tests/hashtag-preferences.test.mjs:307: // TODO: This part of the test is flaky in CI environments.
|
||||
tests/visual/moderation.spec.ts:89: // TODO: Investigate why visibility check fails in fixture environment (headless)
|
||||
tests/visual/moderation.spec.ts:93: // TODO: Investigate why click times out in fixture environment (headless)
|
||||
tests/visual/moderation.spec.ts:166: // TODO: Investigate visibility check failure
|
||||
tests/visual/moderation.spec.ts:170: // TODO: Investigate click timeout failure
|
||||
tests/visual/moderation.spec.ts:219: // TODO: Investigate visibility check failure
|
||||
tests/visual/moderation.spec.ts:223: // TODO: Investigate click timeout failure
|
||||
@@ -1,24 +1,22 @@
|
||||
# Moderation UI "Show Anyway" Button Visibility Issue Reproducer
|
||||
# Moderation UI Failure Reproducer
|
||||
|
||||
This reproducer demonstrates the issue where the "Show anyway" button in the moderation UI is not visible (or not interactable) in the test environment, even though the logic seems correct.
|
||||
This reproducer demonstrates a known issue where the "Show anyway" button and related moderation badges fail to render or be interactable in the test environment (headless mode), despite logic indicating they should be present.
|
||||
|
||||
## Issue Description
|
||||
- **Known Issue**: `KNOWN_ISSUES.md`: Moderation UI (`tests/visual/moderation.spec.ts`): The "Show anyway" button and related moderation badges fail to render in the test environment, causing `toBeVisible()` assertions to fail.
|
||||
- **Symptoms**: `Error: element(s) not found` when asserting visibility of the "Show anyway" button.
|
||||
As noted in `KNOWN_ISSUES.md`:
|
||||
> **Moderation UI (`tests/visual/moderation.spec.ts`)**: The "Show anyway" button and related moderation badges fail to render in the test environment, causing `toBeVisible()` assertions to fail. The logic for creating the button appears correct (`allowOverride` is true), but the button element is not found in the DOM during the test execution.
|
||||
|
||||
## How to Run
|
||||
|
||||
1. Navigate to the repository root.
|
||||
2. Ensure dependencies are installed and the project is built:
|
||||
1. Ensure the project is built:
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
npx playwright install chromium
|
||||
```
|
||||
3. Run the reproducer script using the provided config:
|
||||
|
||||
2. Run the reproducer test using the custom config (which sets the correct test directory and server context):
|
||||
```bash
|
||||
npx playwright test -c examples/reproducers/issue-moderation-ui/playwright.config.ts
|
||||
npx playwright test -c examples/reproducers/playwright.config.ts examples/reproducers/issue-moderation-ui/repro.spec.ts
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
The test `repro: show anyway override button visibility` should **fail** with a timeout or "element not found" error on the assertion `await expect(showAnywayButton).toBeVisible();`.
|
||||
The test should FAIL with timeout errors waiting for the "Show anyway" button to be visible or clickable.
|
||||
@@ -40,6 +40,34 @@ test.afterEach(async ({ page }) => {
|
||||
});
|
||||
|
||||
test.describe("moderation fixtures", () => {
|
||||
test("blur fixture masks thumbnails and blocks autoplay", async ({ page }) => {
|
||||
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
|
||||
await waitForFixtureReady(page);
|
||||
|
||||
const blurCard = page.locator('[data-test-id="blur-threshold"]');
|
||||
const thumbnail = blurCard.locator('img[data-video-thumbnail]');
|
||||
|
||||
await expect(blurCard).toHaveAttribute("data-moderation-report-count", "3");
|
||||
await expect(blurCard).toHaveAttribute("data-autoplay-policy", "blocked");
|
||||
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
await expect(blurCard).not.toHaveAttribute(
|
||||
"data-moderation-override",
|
||||
"show-anyway"
|
||||
);
|
||||
});
|
||||
|
||||
test("autoplay fixture disables preview without blurring", async ({ page }) => {
|
||||
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
|
||||
await waitForFixtureReady(page);
|
||||
|
||||
const autoplayCard = page.locator('[data-test-id="autoplay-threshold"]');
|
||||
const thumbnail = autoplayCard.locator('img[data-video-thumbnail]');
|
||||
|
||||
await expect(autoplayCard).toHaveAttribute("data-moderation-report-count", "2");
|
||||
await expect(autoplayCard).toHaveAttribute("data-autoplay-policy", "blocked");
|
||||
await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
});
|
||||
|
||||
test("show anyway override persists across reloads", async ({ page }) => {
|
||||
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
|
||||
await waitForFixtureReady(page);
|
||||
@@ -59,14 +87,13 @@ test.describe("moderation fixtures", () => {
|
||||
await expect(overrideCard).toHaveAttribute("data-autoplay-policy", "blocked");
|
||||
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
|
||||
// Uncommented failures:
|
||||
// Uncommented failing assertions
|
||||
await expect(showAnywayButton).toBeVisible();
|
||||
await expect(restoreButtonQuery).toHaveCount(0);
|
||||
|
||||
// Uncommented failure:
|
||||
// Uncommented failing interactions
|
||||
await showAnywayButton.click({ force: true });
|
||||
|
||||
/*
|
||||
await expect(overrideCard).toHaveAttribute(
|
||||
"data-moderation-override",
|
||||
"show-anyway"
|
||||
@@ -113,6 +140,105 @@ test.describe("moderation fixtures", () => {
|
||||
await expect(
|
||||
reloadedCard.getByRole("button", { name: "Show anyway" })
|
||||
).toBeVisible();
|
||||
*/
|
||||
});
|
||||
|
||||
test("trusted report hide fixture supports show anyway override", async ({ page }) => {
|
||||
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
|
||||
await waitForFixtureReady(page);
|
||||
|
||||
const hideCard = page.locator('[data-test-id="trusted-report-hide"]');
|
||||
const badge = hideCard.locator('[data-moderation-badge="true"]');
|
||||
|
||||
await expect(hideCard).toHaveAttribute(
|
||||
"data-moderation-override-available",
|
||||
"true"
|
||||
);
|
||||
const showAnywayButton = hideCard.getByRole("button", { name: "Show anyway" });
|
||||
const restoreButtonQuery = hideCard.getByRole("button", {
|
||||
name: RESTORE_BUTTON_LABEL,
|
||||
});
|
||||
|
||||
await expect(hideCard).toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(hideCard).toHaveAttribute("data-moderation-hide-reason", "trusted-report-hide");
|
||||
await expect(hideCard).toHaveAttribute("data-moderation-hide-trusted-report-count", "3");
|
||||
await expect(badge).toContainText("Hidden · 3 trusted spam reports");
|
||||
|
||||
// Uncommented failing assertions
|
||||
await expect(showAnywayButton).toBeVisible();
|
||||
await expect(restoreButtonQuery).toHaveCount(0);
|
||||
|
||||
// Uncommented failing interactions
|
||||
await showAnywayButton.click({ force: true });
|
||||
|
||||
await expect(hideCard).not.toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(hideCard).toHaveAttribute("data-moderation-override", "show-anyway");
|
||||
await expect(hideCard.locator('[data-moderation-badge="true"]')).toContainText(
|
||||
"Showing despite 3 trusted spam reports",
|
||||
);
|
||||
|
||||
const restoreButton = hideCard.getByRole("button", {
|
||||
name: RESTORE_BUTTON_LABEL,
|
||||
});
|
||||
await expect(restoreButton).toBeVisible();
|
||||
|
||||
await restoreButton.click();
|
||||
|
||||
await expect(hideCard).toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(hideCard).not.toHaveAttribute("data-moderation-override", "show-anyway");
|
||||
await expect(hideCard.getByRole("button", { name: "Show anyway" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("trusted mute hide fixture annotates mute reason and can be overridden", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
|
||||
await waitForFixtureReady(page);
|
||||
|
||||
const muteCard = page.locator('[data-test-id="trusted-mute-hide"]');
|
||||
const badge = muteCard.locator('[data-moderation-badge="true"]');
|
||||
const thumbnail = muteCard.locator('img[data-video-thumbnail]');
|
||||
|
||||
await expect(muteCard).toHaveAttribute(
|
||||
"data-moderation-override-available",
|
||||
"true"
|
||||
);
|
||||
const showAnywayButton = muteCard.getByRole("button", { name: "Show anyway" });
|
||||
const restoreButtonQuery = muteCard.getByRole("button", {
|
||||
name: RESTORE_BUTTON_LABEL,
|
||||
});
|
||||
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-hide-reason", "trusted-mute-hide");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-hide-trusted-mute-count", "1");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute", "true");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute-count", "1");
|
||||
await expect(badge).toContainText("Hidden · 1 trusted mute");
|
||||
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
|
||||
// Uncommented failing assertions
|
||||
await expect(showAnywayButton).toBeVisible();
|
||||
await expect(restoreButtonQuery).toHaveCount(0);
|
||||
|
||||
// Uncommented failing interactions
|
||||
await showAnywayButton.click({ force: true });
|
||||
|
||||
await expect(muteCard).not.toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-override", "show-anyway");
|
||||
await expect(muteCard.locator('[data-moderation-badge="true"]')).toContainText(
|
||||
"Showing despite 1 trusted mute",
|
||||
);
|
||||
await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
|
||||
const restoreButton = muteCard.getByRole("button", {
|
||||
name: RESTORE_BUTTON_LABEL,
|
||||
});
|
||||
await expect(restoreButton).toBeVisible();
|
||||
|
||||
await restoreButton.click();
|
||||
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(muteCard).not.toHaveAttribute("data-moderation-override", "show-anyway");
|
||||
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
await expect(muteCard.getByRole("button", { name: "Show anyway" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,18 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const HOST = process.env.PLAYWRIGHT_HOST ?? "127.0.0.1";
|
||||
const PORT = Number.parseInt(process.env.PLAYWRIGHT_PORT ?? "4173", 10);
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${HOST}:${PORT}`;
|
||||
import baseConfig from "../../playwright.config.ts";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "../../",
|
||||
outputDir: "../../artifacts/test-results-repro",
|
||||
timeout: 60_000,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
...baseConfig,
|
||||
testDir: ".",
|
||||
webServer: {
|
||||
...baseConfig.webServer,
|
||||
cwd: "../../",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "repro",
|
||||
testMatch: "examples/reproducers/**/*.spec.ts",
|
||||
testMatch: "**/*.spec.ts",
|
||||
use: baseConfig.use,
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: `npx http-server . -p ${PORT} -a ${HOST} -c-1 --silent`,
|
||||
cwd: "../../",
|
||||
url: `${BASE_URL}/docs/kitchen-sink.html`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
# Security Review: Upgrade nostr-tools to 2.19.4
|
||||
|
||||
## Issue
|
||||
There is a version mismatch between the declared dependency in `package.json` and the runtime import in the browser.
|
||||
|
||||
- **`package.json`**: `"nostr-tools": "^2.19.4"`
|
||||
- **`js/nostrToolsBootstrap.js`**: Hardcodes `2.17.0` from `esm.sh` and `jsdelivr`.
|
||||
|
||||
## Symptoms
|
||||
The browser console logs numerous errors from relays indicating invalid commands:
|
||||
```
|
||||
console:debug: NOTICE from wss://relay.damus.io/: ERROR: bad msg: unknown cmd @ https://esm.sh/[email protected]/es2022/nostr-tools.mjs:1
|
||||
console:debug: NOTICE from wss://relay.primal.net/: ERROR: bad msg: unknown cmd @ https://esm.sh/[email protected]/es2022/nostr-tools.mjs:1
|
||||
```
|
||||
This suggests that the client (running `2.17.0`) might be constructing messages (e.g. NIP-45 `COUNT` or others) in a way that is incompatible with the relays or missing protocol updates present in `2.19.4`.
|
||||
|
||||
## Policy
|
||||
`AGENTS.md` states:
|
||||
> Upgrades to cryptographic or protocol libraries (specifically `nostr-tools`) are restricted and require manual review; automatic upgrades are prohibited to prevent breaking NIP implementations.
|
||||
|
||||
## Action Required
|
||||
A manual security review is required to update `js/nostrToolsBootstrap.js` to match the version in `package.json` (`2.19.4`).
|
||||
|
||||
## Secondary Finding
|
||||
The following resource fails to load (404):
|
||||
```
|
||||
https://pub-ca50f43da1364bf6a552b90443fcdc64.r2.dev/bitvid-logo-loop.mp4
|
||||
```
|
||||
This appears to be a missing content asset.
|
||||
@@ -0,0 +1,27 @@
|
||||
# todo: Investigate flaky hashtag preferences test in CI
|
||||
|
||||
**File:** `tests/hashtag-preferences.test.mjs` (Line 307)
|
||||
**Priority:** Medium
|
||||
**Tags:** ai, needs-review
|
||||
|
||||
**Description:**
|
||||
The test "load defers permission-required decrypts until explicitly enabled" contains commented-out code marked with a TODO indicating flakiness in CI environments.
|
||||
|
||||
**Context:**
|
||||
```javascript
|
||||
// TODO: This part of the test is flaky in CI environments.
|
||||
// Logs confirm the code attempts decryption ("Attempting decryption via window.nostr fallback"),
|
||||
// but the mock spy is not consistently called, likely due to runNip07WithRetry/microtask timing.
|
||||
//
|
||||
// await hashtagPreferences.load(pubkey, { allowPermissionPrompt: true });
|
||||
//
|
||||
// assert.equal(
|
||||
// decryptCalls.length,
|
||||
// 1,
|
||||
// "explicit permission prompts should retry decryption",
|
||||
// );
|
||||
// assert.deepEqual(hashtagPreferences.getInterests(), ["late"]);
|
||||
```
|
||||
|
||||
**Suggested Next Step:**
|
||||
Investigate the timing issues with `runNip07WithRetry` and the microtask queue to reliably mock the permission prompt behavior in tests.
|
||||
@@ -0,0 +1,24 @@
|
||||
# todo: Investigate failing visual moderation tests in headless mode
|
||||
|
||||
**File:** `tests/visual/moderation.spec.ts`
|
||||
**Priority:** Medium
|
||||
**Tags:** ai, needs-review
|
||||
|
||||
**Description:**
|
||||
Multiple tests in `tests/visual/moderation.spec.ts` have commented-out assertions due to failures in headless/CI environments. These failures relate to visibility checks and click timeouts.
|
||||
|
||||
**Context:**
|
||||
```typescript
|
||||
// TODO: Investigate why visibility check fails in fixture environment (headless)
|
||||
// await expect(showAnywayButton).toBeVisible();
|
||||
|
||||
// TODO: Investigate why click times out in fixture environment (headless)
|
||||
// await showAnywayButton.click({ force: true });
|
||||
```
|
||||
This pattern repeats in:
|
||||
- "show anyway override persists across reloads"
|
||||
- "trusted report hide fixture supports show anyway override"
|
||||
- "trusted mute hide fixture annotates mute reason and can be overridden"
|
||||
|
||||
**Suggested Next Step:**
|
||||
Investigate why the headless environment (fixture) causes these visibility checks and clicks to fail. It might be related to rendering speed, element overlap, or how the fixture loads.
|
||||
@@ -3,6 +3,7 @@
|
||||
import { signEventWithPrivateKey } from "../publishHelpers.js";
|
||||
import { createPrivateKeyCipherClosures } from "../signerHelpers.js";
|
||||
import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js";
|
||||
import { hexToBytes } from "../../utils/hex.js";
|
||||
import { normalizeActorKey } from "../watchHistory.js";
|
||||
|
||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
@@ -30,7 +31,7 @@ async function resolvePublicKey(privateKey, pubkey) {
|
||||
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (tools?.getPublicKey && typeof tools.getPublicKey === "function") {
|
||||
const derived = tools.getPublicKey(privateKey);
|
||||
const derived = tools.getPublicKey(hexToBytes(privateKey));
|
||||
const normalized = normalizeActorKey(derived);
|
||||
if (normalized && HEX64_REGEX.test(normalized)) {
|
||||
return normalized;
|
||||
|
||||
+26
-17
@@ -130,6 +130,7 @@ import {
|
||||
summarizeDmEventForLog,
|
||||
} from "./dmDecryptDiagnostics.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { hexToBytes } from "../utils/hex.js";
|
||||
import { LRUCache } from "../utils/lruCache.js";
|
||||
import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js";
|
||||
import {
|
||||
@@ -1629,7 +1630,7 @@ export class NostrClient {
|
||||
if (!tools || typeof tools.getPublicKey !== "function") {
|
||||
throw new Error("Public key derivation is unavailable for remote signing.");
|
||||
}
|
||||
publicKey = tools.getPublicKey(privateKey);
|
||||
publicKey = tools.getPublicKey(hexToBytes(privateKey));
|
||||
}
|
||||
|
||||
if (!publicKey || !HEX64_REGEX.test(publicKey)) {
|
||||
@@ -2275,7 +2276,7 @@ export class NostrClient {
|
||||
if (!tools || typeof tools.getPublicKey !== "function") {
|
||||
throw new Error("Public key derivation is unavailable for the remote signer handshake.");
|
||||
}
|
||||
clientPublicKey = normalizeNostrPubkey(tools.getPublicKey(clientPrivateKey));
|
||||
clientPublicKey = normalizeNostrPubkey(tools.getPublicKey(hexToBytes(clientPrivateKey)));
|
||||
}
|
||||
|
||||
if (!clientPublicKey || !HEX64_REGEX.test(clientPublicKey)) {
|
||||
@@ -3440,7 +3441,7 @@ export class NostrClient {
|
||||
|
||||
let pubkey = "";
|
||||
try {
|
||||
pubkey = getPublicKey(normalizedPrivateKey);
|
||||
pubkey = getPublicKey(hexToBytes(normalizedPrivateKey));
|
||||
} catch (error) {
|
||||
let retrySuccess = false;
|
||||
try {
|
||||
@@ -3551,7 +3552,7 @@ export class NostrClient {
|
||||
|
||||
let pubkey = "";
|
||||
try {
|
||||
pubkey = getPublicKey(privateKey);
|
||||
pubkey = getPublicKey(hexToBytes(privateKey));
|
||||
} catch (error) {
|
||||
const failure = new Error("Failed to derive the public key.");
|
||||
failure.cause = error;
|
||||
@@ -3595,7 +3596,7 @@ export class NostrClient {
|
||||
throw new Error("Public key derivation is unavailable.");
|
||||
}
|
||||
try {
|
||||
normalizedPubkey = getPublicKey(normalizedPrivateKey);
|
||||
normalizedPubkey = getPublicKey(hexToBytes(normalizedPrivateKey));
|
||||
} catch (error) {
|
||||
const failure = new Error("Failed to derive the public key.");
|
||||
failure.cause = error;
|
||||
@@ -3733,7 +3734,7 @@ export class NostrClient {
|
||||
throw new Error("Public key derivation is unavailable.");
|
||||
}
|
||||
try {
|
||||
normalizedPubkey = getPublicKey(normalizedPrivateKey);
|
||||
normalizedPubkey = getPublicKey(hexToBytes(normalizedPrivateKey));
|
||||
} catch (error) {
|
||||
const failure = new Error("Failed to derive the public key.");
|
||||
failure.cause = error;
|
||||
@@ -7650,6 +7651,7 @@ export class NostrClient {
|
||||
|
||||
const toProcess = eventBuffer;
|
||||
eventBuffer = [];
|
||||
const videosNeedingMetadata = [];
|
||||
|
||||
for (const evt of toProcess) {
|
||||
try {
|
||||
@@ -7706,23 +7708,30 @@ export class NostrClient {
|
||||
this.activeMap.set(activeKey, video);
|
||||
onVideo(video); // Trigger the callback that re-renders
|
||||
|
||||
// Fetch NIP-71 metadata (categorization tags) in the background
|
||||
this.populateNip71MetadataForVideos([video])
|
||||
.then(() => {
|
||||
this.applyRootCreatedAt(video);
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to hydrate NIP-71 metadata for live video:",
|
||||
error
|
||||
);
|
||||
});
|
||||
// Defer NIP-71 fetch for batching
|
||||
videosNeedingMetadata.push(video);
|
||||
}
|
||||
} catch (err) {
|
||||
devLogger.error("[subscribeVideos] Error processing event:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (videosNeedingMetadata.length > 0) {
|
||||
// Fetch NIP-71 metadata (categorization tags) in the background
|
||||
this.populateNip71MetadataForVideos(videosNeedingMetadata)
|
||||
.then(() => {
|
||||
for (const video of videosNeedingMetadata) {
|
||||
this.applyRootCreatedAt(video);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to hydrate NIP-71 metadata for live video batch:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Persist processed events after each flush so reloads warm quickly.
|
||||
this.saveLocalData("subscribeVideos:flush");
|
||||
};
|
||||
|
||||
+10
-3
@@ -25,6 +25,7 @@ import {
|
||||
assertAnyRelayAccepted as defaultAssertAnyRelayAccepted,
|
||||
} from "../nostrPublish.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { hexToBytes } from "../utils/hex.js";
|
||||
import { Nip46RequestQueue, NIP46_PRIORITY } from "./nip46Queue.js";
|
||||
|
||||
export const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||
@@ -1071,7 +1072,10 @@ function resolveAvailableNip46Ciphers(
|
||||
typeof nip44v2GetConversationKey === "function"
|
||||
) {
|
||||
registerCipher("nip44.v2", () => {
|
||||
const conversationKey = nip44v2GetConversationKey(privateKey, remotePubkey);
|
||||
const conversationKey = nip44v2GetConversationKey(
|
||||
hexToBytes(privateKey),
|
||||
remotePubkey,
|
||||
);
|
||||
|
||||
if (!conversationKey) {
|
||||
throw new Error("Failed to derive a nip44 conversation key for remote signing.");
|
||||
@@ -1095,7 +1099,10 @@ function resolveAvailableNip46Ciphers(
|
||||
typeof nip44GetConversationKey === "function"
|
||||
) {
|
||||
registerCipher("nip44", () => {
|
||||
const conversationKey = nip44GetConversationKey(privateKey, remotePubkey);
|
||||
const conversationKey = nip44GetConversationKey(
|
||||
hexToBytes(privateKey),
|
||||
remotePubkey,
|
||||
);
|
||||
|
||||
if (!conversationKey) {
|
||||
throw new Error("Failed to derive a nip44 conversation key for remote signing.");
|
||||
@@ -1494,7 +1501,7 @@ export class Nip46RpcClient {
|
||||
if (!tools || typeof tools.getPublicKey !== "function") {
|
||||
throw new Error("Public key derivation is unavailable.");
|
||||
}
|
||||
this.clientPublicKey = tools.getPublicKey(this.clientPrivateKey);
|
||||
this.clientPublicKey = tools.getPublicKey(hexToBytes(this.clientPrivateKey));
|
||||
if (!this.clientPublicKey || !HEX64_REGEX.test(this.clientPublicKey)) {
|
||||
throw new Error("Failed to derive a valid public key for the remote signer session.");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getCachedNostrTools,
|
||||
readToolkitFromScope,
|
||||
} from "./toolkit.js";
|
||||
import { hexToBytes } from "../utils/hex.js";
|
||||
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
import { logRebroadcastCountFailure } from "./countDiagnostics.js";
|
||||
@@ -134,7 +135,7 @@ export function signEventWithPrivateKey(event, privateKey) {
|
||||
throw new Error("Missing signing primitives");
|
||||
}
|
||||
|
||||
const finalized = finalizeFn(prepared, normalizedKey);
|
||||
const finalized = finalizeFn(prepared, hexToBytes(normalizedKey));
|
||||
if (
|
||||
!finalized ||
|
||||
typeof finalized.id !== "string" ||
|
||||
|
||||
@@ -9,3 +9,21 @@ export function normalizeHexString(value) {
|
||||
|
||||
export const normalizeHexId = normalizeHexString;
|
||||
export const normalizeHexPubkey = normalizeHexString;
|
||||
|
||||
export function hexToBytes(hex) {
|
||||
if (typeof hex !== "string") {
|
||||
throw new TypeError("hexToBytes: expected string, got " + typeof hex);
|
||||
}
|
||||
const normalized = hex.trim();
|
||||
if (normalized.length % 2) throw new Error("hex string must have even length");
|
||||
const array = new Uint8Array(normalized.length / 2);
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const j = i * 2;
|
||||
const byte = parseInt(normalized.substring(j, j + 2), 16);
|
||||
if (Number.isNaN(byte) || byte < 0) {
|
||||
throw new Error("Invalid hex string");
|
||||
}
|
||||
array[i] = byte;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
Generated
+21
-88
@@ -816,31 +816,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
@@ -900,21 +891,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
|
||||
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
@@ -925,62 +904,28 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
|
||||
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
|
||||
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "~1.3.0",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
|
||||
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -2825,18 +2770,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-wasm": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Release Draft - 2026-01-31
|
||||
|
||||
## Highlights
|
||||
|
||||
### Webseed Stability (Unstable)
|
||||
|
||||
A recent change aimed at fixing a webseed regression was reverted (PR #2002) to ensure stability in the unstable channel. This restores the previous behavior while a more robust fix is investigated.
|
||||
|
||||
### Major Repository Update (Main)
|
||||
|
||||
The main branch has received a significant update (`b429b1c`), incorporating extensive changes across the codebase. This synchronization brings `main` up to date with the latest developments.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
No specific manual migration steps are required, but developers should be aware of the large changeset in `main`.
|
||||
@@ -1,23 +1,37 @@
|
||||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
def run():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
async def run():
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
page = await browser.new_page()
|
||||
|
||||
page.on("console", lambda msg: print(f"console:{msg.type}: {msg.text}"))
|
||||
# Listen for console logs with location
|
||||
def handle_console(msg):
|
||||
loc = msg.location
|
||||
print(f"console:{msg.type}: {msg.text} @ {loc['url']}:{loc['lineNumber']}")
|
||||
|
||||
page.on("console", handle_console)
|
||||
|
||||
# Listen for exceptions
|
||||
page.on("pageerror", lambda exc: print(f"pageerror: {exc}"))
|
||||
page.on("requestfailed", lambda req: print(f"requestfailed: {req.url} - {req.failure}"))
|
||||
|
||||
# Listen for failed requests
|
||||
page.on("requestfailed", lambda req: print(f"requestfailed: {req.url} {req.failure}"))
|
||||
|
||||
# Listen for 404s specifically
|
||||
page.on("response", lambda res: print(f"response:{res.status}: {res.url}") if res.status >= 400 else None)
|
||||
|
||||
try:
|
||||
page.goto("http://localhost:8000")
|
||||
# Wait a bit for JS to execute and errors to appear
|
||||
page.wait_for_timeout(5000)
|
||||
print("Navigating to http://localhost:8000...")
|
||||
await page.goto("http://localhost:8000")
|
||||
# Wait a bit for initialization
|
||||
await page.wait_for_timeout(5000)
|
||||
except Exception as e:
|
||||
print(f"Error navigating: {e}")
|
||||
print(f"Navigation error: {e}")
|
||||
finally:
|
||||
browser.close()
|
||||
await browser.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
asyncio.run(run())
|
||||
+234
-177
@@ -1,185 +1,242 @@
|
||||
|
||||
import './setup-test-env.js';
|
||||
import { NostrClient } from '../../js/nostr/client.js';
|
||||
import { buildVideoPostEvent, buildViewEvent, NOTE_TYPES } from '../../js/nostrEventSchemas.js';
|
||||
import * as NostrTools from 'nostr-tools';
|
||||
import { decryptDM } from '../../js/dmDecryptor.js';
|
||||
import {
|
||||
buildVideoPostEvent,
|
||||
buildLegacyDirectMessageEvent,
|
||||
buildViewEvent,
|
||||
NOTE_TYPES
|
||||
} from '../../js/nostrEventSchemas.js';
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const bytesToHex = (bytes) => {
|
||||
return Buffer.from(bytes).toString('hex');
|
||||
};
|
||||
|
||||
const RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol"
|
||||
// Configuration
|
||||
const TEST_RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol'
|
||||
];
|
||||
|
||||
async function runTests() {
|
||||
console.log("Starting Protocol & Interop Tests...");
|
||||
// Artifacts
|
||||
const ARTIFACTS_DIR = 'artifacts';
|
||||
const LOG_FILE = path.join(ARTIFACTS_DIR, `interop-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.log`);
|
||||
|
||||
// --- Setup ---
|
||||
console.log("[Setup] Generating ephemeral keys...");
|
||||
const aliceSkBytes = NostrTools.generateSecretKey();
|
||||
const aliceSk = bytesToHex(aliceSkBytes);
|
||||
const alicePk = NostrTools.getPublicKey(aliceSkBytes);
|
||||
|
||||
const bobSkBytes = NostrTools.generateSecretKey();
|
||||
const bobSk = bytesToHex(bobSkBytes);
|
||||
const bobPk = NostrTools.getPublicKey(bobSkBytes);
|
||||
|
||||
console.log(`[Setup] Alice: ${alicePk}`);
|
||||
console.log(`[Setup] Bob: ${bobPk}`);
|
||||
|
||||
const aliceClient = new NostrClient();
|
||||
aliceClient.relays = [...RELAYS];
|
||||
aliceClient.readRelays = [...RELAYS];
|
||||
aliceClient.writeRelays = [...RELAYS];
|
||||
|
||||
const bobClient = new NostrClient();
|
||||
bobClient.relays = [...RELAYS];
|
||||
bobClient.readRelays = [...RELAYS];
|
||||
bobClient.writeRelays = [...RELAYS];
|
||||
|
||||
console.log("[Setup] Connecting clients...");
|
||||
// Initialize to connect
|
||||
await aliceClient.init(); // This might try to load stored session, but shouldn't matter
|
||||
await bobClient.init();
|
||||
|
||||
// Register signers
|
||||
await aliceClient.registerPrivateKeySigner({ privateKey: aliceSk, pubkey: alicePk });
|
||||
aliceClient.pubkey = alicePk; // Explicitly set client pubkey to ensure correct signer resolution
|
||||
|
||||
await bobClient.registerPrivateKeySigner({ privateKey: bobSk, pubkey: bobPk });
|
||||
bobClient.pubkey = bobPk; // Explicitly set client pubkey to ensure correct signer resolution
|
||||
|
||||
let testsPassed = 0;
|
||||
let testsTotal = 0;
|
||||
|
||||
// --- Test A: Video Post ---
|
||||
testsTotal++;
|
||||
console.log("\n[Test A] Publishing VIDEO_POST...");
|
||||
try {
|
||||
const videoPayload = {
|
||||
version: 3,
|
||||
title: "Interop Test Video " + Date.now(),
|
||||
description: "This is a test video event.",
|
||||
videoRootId: "interop-test-" + Date.now(),
|
||||
mode: "dev",
|
||||
isPrivate: false,
|
||||
isNsfw: false,
|
||||
isForKids: false,
|
||||
enableComments: true
|
||||
};
|
||||
|
||||
// We use signAndPublishEvent directly with a manually built event to test the schema helper
|
||||
const event = buildVideoPostEvent({
|
||||
pubkey: alicePk,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: videoPayload.videoRootId,
|
||||
content: videoPayload
|
||||
});
|
||||
|
||||
const { signedEvent } = await aliceClient.signAndPublishEvent(event);
|
||||
console.log(`[Test A] Published event ${signedEvent.id}`);
|
||||
|
||||
// Verify
|
||||
console.log("[Test A] Verifying roundtrip...");
|
||||
// Give relays a moment
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// getEventById returns the Video object (converted), but we can ask for raw too
|
||||
const result = await bobClient.getEventById(signedEvent.id, { includeRaw: true });
|
||||
if (!result || !result.rawEvent) throw new Error("Failed to fetch event back");
|
||||
|
||||
const fetched = result.rawEvent;
|
||||
if (fetched.id !== signedEvent.id) throw new Error("Fetched ID mismatch");
|
||||
|
||||
// Parse content
|
||||
const content = typeof fetched.content === 'string' ? JSON.parse(fetched.content) : fetched.content;
|
||||
if (content.title !== videoPayload.title) throw new Error("Content mismatch: title");
|
||||
|
||||
console.log("[Test A] PASSED");
|
||||
testsPassed++;
|
||||
} catch (e) {
|
||||
console.error("[Test A] FAILED:", e);
|
||||
}
|
||||
|
||||
// --- Test B: View Event ---
|
||||
testsTotal++;
|
||||
console.log("\n[Test B] Publishing VIEW_EVENT...");
|
||||
try {
|
||||
const viewEvent = buildViewEvent({
|
||||
pubkey: alicePk,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pointerValue: "test-video-pointer", // Arbitrary pointer for test
|
||||
content: "view-test"
|
||||
});
|
||||
|
||||
const { signedEvent: signedView } = await aliceClient.signAndPublishEvent(viewEvent);
|
||||
console.log(`[Test B] Published view event ${signedView.id}`);
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Bob fetches raw event
|
||||
const fetchedView = await bobClient.fetchRawEventById(signedView.id);
|
||||
if (!fetchedView) throw new Error("Failed to fetch view event");
|
||||
if (fetchedView.id !== signedView.id) throw new Error("View event ID mismatch");
|
||||
|
||||
console.log("[Test B] PASSED");
|
||||
testsPassed++;
|
||||
} catch (e) {
|
||||
console.error("[Test B] FAILED:", e);
|
||||
}
|
||||
|
||||
// --- Test C: Direct Message ---
|
||||
testsTotal++;
|
||||
console.log("\n[Test C] Sending Direct Message...");
|
||||
try {
|
||||
const message = "Hello Bob, this is Alice " + Date.now();
|
||||
const npubBob = NostrTools.nip19.npubEncode(bobPk);
|
||||
|
||||
const sendResult = await aliceClient.sendDirectMessage(npubBob, message);
|
||||
if (!sendResult.ok) throw new Error("Failed to send DM: " + (sendResult.error || "unknown"));
|
||||
console.log("[Test C] DM sent successfully");
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
console.log("[Test C] Bob checking messages...");
|
||||
// Force bob to list messages (which triggers decryption using his registered signer)
|
||||
const messages = await bobClient.listDirectMessages(bobPk, { limit: 5 });
|
||||
|
||||
const received = messages.find(m => m.plaintext === message);
|
||||
if (!received) {
|
||||
console.log("Received messages:", messages.map(m => m.plaintext));
|
||||
throw new Error("Bob did not find the specific message");
|
||||
}
|
||||
|
||||
if (received.sender.pubkey !== alicePk) {
|
||||
console.log(`[Test C] Sender mismatch: Expected ${alicePk}, got ${received.sender.pubkey}`);
|
||||
throw new Error("Sender mismatch");
|
||||
}
|
||||
|
||||
console.log("[Test C] PASSED");
|
||||
testsPassed++;
|
||||
} catch (e) {
|
||||
console.error("[Test C] FAILED:", e);
|
||||
}
|
||||
|
||||
// --- Summary ---
|
||||
console.log("\n---------------------------------------------------");
|
||||
console.log(`Tests Completed: ${testsPassed}/${testsTotal}`);
|
||||
|
||||
aliceClient.logout();
|
||||
bobClient.logout();
|
||||
|
||||
if (testsPassed === testsTotal) {
|
||||
console.log("ALL TESTS PASSED");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("SOME TESTS FAILED");
|
||||
process.exit(1);
|
||||
}
|
||||
// Logger
|
||||
function log(message) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const line = `[${timestamp}] ${message}`;
|
||||
console.log(line);
|
||||
fs.appendFileSync(LOG_FILE, line + '\n');
|
||||
}
|
||||
|
||||
runTests().catch(e => {
|
||||
console.error("Unhandled execution error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
// Generate Keys
|
||||
const bytesToHex = (bytes) => Buffer.from(bytes).toString('hex');
|
||||
const EPHEMERAL_SK_BYTES = generateSecretKey();
|
||||
const EPHEMERAL_SK = bytesToHex(EPHEMERAL_SK_BYTES);
|
||||
const EPHEMERAL_PK = getPublicKey(EPHEMERAL_SK_BYTES);
|
||||
|
||||
async function runInteropTest() {
|
||||
// Ensure artifacts dir
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) {
|
||||
fs.mkdirSync(ARTIFACTS_DIR);
|
||||
}
|
||||
fs.writeFileSync(LOG_FILE, ''); // Clear log
|
||||
|
||||
let nodeClient;
|
||||
let exitCode = 0;
|
||||
const artifacts = {};
|
||||
|
||||
try {
|
||||
log('--- Interop Test Started ---');
|
||||
log(`Ephemeral Pubkey: ${EPHEMERAL_PK}`);
|
||||
log(`Relays: ${TEST_RELAYS.join(', ')}`);
|
||||
|
||||
// 1. Initialize Node Client
|
||||
log('Initializing NostrClient...');
|
||||
nodeClient = new NostrClient();
|
||||
nodeClient.relays = [...TEST_RELAYS];
|
||||
nodeClient.writeRelays = [...TEST_RELAYS];
|
||||
nodeClient.readRelays = [...TEST_RELAYS];
|
||||
|
||||
// Register ephemeral signer
|
||||
await nodeClient.registerPrivateKeySigner({ privateKey: EPHEMERAL_SK });
|
||||
log('Signer registered.');
|
||||
|
||||
// Initialize pool (lazy, but explicit init ensures connection attempts)
|
||||
log('Warming up connections...');
|
||||
await nodeClient.ensurePool(); // Ensure pool is created
|
||||
|
||||
// 2. Test A: Video Post
|
||||
log('Test A: Publishing VIDEO_POST...');
|
||||
const videoEventTemplate = buildVideoPostEvent({
|
||||
pubkey: EPHEMERAL_PK,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: `interop-test-${Date.now()}`,
|
||||
content: {
|
||||
version: 3,
|
||||
title: 'Interop Test Video',
|
||||
url: 'https://example.com/video.mp4',
|
||||
description: 'A video to verify protocol interoperability.',
|
||||
mode: 'live',
|
||||
videoRootId: `interop-root-${Date.now()}`
|
||||
}
|
||||
});
|
||||
|
||||
const { signedEvent: publishedVideo } = await nodeClient.signAndPublishEvent(videoEventTemplate, {
|
||||
context: 'interop-video'
|
||||
});
|
||||
log(`Published Video ID: ${publishedVideo.id}`);
|
||||
artifacts.videoEventId = publishedVideo.id;
|
||||
|
||||
// Fetch back verification with Retry
|
||||
log('Verifying Video Post (fetch by ID)...');
|
||||
let fetchedVideo = null;
|
||||
const maxRetries = 5;
|
||||
const initialDelay = 2000;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const delay = initialDelay * (i + 1);
|
||||
log(`Attempt ${i + 1}/${maxRetries}: Waiting ${delay}ms...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
|
||||
try {
|
||||
fetchedVideo = await nodeClient.fetchRawEventById(publishedVideo.id);
|
||||
if (fetchedVideo) {
|
||||
log('Video event found!');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Fetch attempt failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetchedVideo) {
|
||||
throw new Error(`Failed to fetch video event ${publishedVideo.id} after ${maxRetries} retries.`);
|
||||
}
|
||||
if (fetchedVideo.content !== publishedVideo.content) {
|
||||
throw new Error('Fetched video content mismatch.');
|
||||
}
|
||||
log('Video Post verified successfully.');
|
||||
|
||||
|
||||
// 3. Test B: View Event
|
||||
log('Test B: Publishing VIEW_EVENT...');
|
||||
const viewEventTemplate = buildViewEvent({
|
||||
pubkey: EPHEMERAL_PK,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pointerValue: publishedVideo.id, // Pointing to the video we just created
|
||||
pointerTag: ['e', publishedVideo.id],
|
||||
includeSessionTag: true // simulate session
|
||||
});
|
||||
|
||||
const { signedEvent: publishedView } = await nodeClient.signAndPublishEvent(viewEventTemplate, {
|
||||
context: 'interop-view'
|
||||
});
|
||||
log(`Published View Event ID: ${publishedView.id}`);
|
||||
artifacts.viewEventId = publishedView.id;
|
||||
|
||||
// No fetch back for view event to save time, assuming if Video worked, this works too.
|
||||
log('View Event published.');
|
||||
|
||||
|
||||
// 4. Test C: Direct Message (Legacy NIP-04)
|
||||
log('Test C: Publishing Encrypted DM...');
|
||||
const dmMessage = "Interop Test Secret Message";
|
||||
const signer = await nodeClient.ensureActiveSignerForPubkey(EPHEMERAL_PK);
|
||||
|
||||
// Encrypt (Self-DM)
|
||||
const ciphertext = await signer.nip04Encrypt(EPHEMERAL_PK, dmMessage);
|
||||
|
||||
const dmTemplate = buildLegacyDirectMessageEvent({
|
||||
pubkey: EPHEMERAL_PK,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
recipientPubkey: EPHEMERAL_PK,
|
||||
ciphertext: ciphertext
|
||||
});
|
||||
|
||||
const { signedEvent: publishedDM } = await nodeClient.signAndPublishEvent(dmTemplate, {
|
||||
context: 'interop-dm'
|
||||
});
|
||||
log(`Published DM ID: ${publishedDM.id}`);
|
||||
artifacts.dmEventId = publishedDM.id;
|
||||
|
||||
// Fetch and Decrypt with Retry
|
||||
log('Verifying DM Decryption...');
|
||||
let fetchedDM = null;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const delay = initialDelay * (i + 1);
|
||||
log(`Attempt ${i + 1}/${maxRetries}: Waiting ${delay}ms...`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
|
||||
try {
|
||||
fetchedDM = await nodeClient.fetchRawEventById(publishedDM.id);
|
||||
if (fetchedDM) {
|
||||
log('DM event found!');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Fetch attempt failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetchedDM) {
|
||||
throw new Error(`Failed to fetch DM event ${publishedDM.id} after ${maxRetries} retries.`);
|
||||
}
|
||||
|
||||
const decryptContext = await nodeClient.buildDmDecryptContext(EPHEMERAL_PK);
|
||||
const decryptionResult = await decryptDM(fetchedDM, decryptContext);
|
||||
|
||||
if (!decryptionResult.ok) {
|
||||
log(`Decryption failed: ${JSON.stringify(decryptionResult.errors)}`);
|
||||
throw new Error('DM Decryption failed.');
|
||||
}
|
||||
|
||||
if (decryptionResult.plaintext !== dmMessage) {
|
||||
throw new Error(`Decryption mismatch. Expected "${dmMessage}", got "${decryptionResult.plaintext}"`);
|
||||
}
|
||||
log(`DM Decrypted successfully: "${decryptionResult.plaintext}"`);
|
||||
|
||||
log('--- Interop Test PASSED ---');
|
||||
|
||||
// Save Summary
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "success",
|
||||
relays: TEST_RELAYS,
|
||||
ephemeralPubkey: EPHEMERAL_PK,
|
||||
artifacts
|
||||
};
|
||||
const summaryFile = path.join(ARTIFACTS_DIR, `interop-summary-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.json`);
|
||||
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
|
||||
log(`Summary saved to ${summaryFile}`);
|
||||
|
||||
} catch (err) {
|
||||
log(`--- Interop Test FAILED: ${err.message} ---`);
|
||||
if (err.stack) log(err.stack);
|
||||
exitCode = 1;
|
||||
|
||||
// Save Failure Summary
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "failed",
|
||||
error: err.message,
|
||||
relays: TEST_RELAYS,
|
||||
ephemeralPubkey: EPHEMERAL_PK,
|
||||
artifacts
|
||||
};
|
||||
const summaryFile = path.join(ARTIFACTS_DIR, `interop-summary-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.json`);
|
||||
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
|
||||
|
||||
} finally {
|
||||
log('Cleaning up...');
|
||||
if (nodeClient && nodeClient.pool) {
|
||||
// Close pool connections if possible (NostrTools SimplePool might not expose close easily in all versions,
|
||||
// but we can try client logic if implemented or just exit)
|
||||
// The client logic might not fully close sockets immediately, process.exit will handle it.
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
runInteropTest();
|
||||
@@ -1,271 +0,0 @@
|
||||
|
||||
import './setup-test-env.js';
|
||||
import { startRelay } from './load-test-relay.mjs';
|
||||
import { NostrClient } from '../../js/nostr/client.js';
|
||||
import { decryptDM } from '../../js/dmDecryptor.js';
|
||||
import {
|
||||
buildVideoPostEvent,
|
||||
buildLegacyDirectMessageEvent
|
||||
} from '../../js/nostrEventSchemas.js';
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const RELAY_PORT = 8899;
|
||||
const RELAY_URL = `ws://localhost:${RELAY_PORT}`;
|
||||
const HTTP_PORT = 8000;
|
||||
const HTTP_URL = `http://localhost:${HTTP_PORT}`;
|
||||
|
||||
// Artifacts
|
||||
const ARTIFACTS_DIR = 'artifacts';
|
||||
const LOG_FILE = path.join(ARTIFACTS_DIR, `smoke-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.log`);
|
||||
const SCREENSHOT_FILE = path.join(ARTIFACTS_DIR, 'smoke-test-ui.png');
|
||||
|
||||
// Logger
|
||||
function log(message) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const line = `[${timestamp}] ${message}`;
|
||||
console.log(line);
|
||||
fs.appendFileSync(LOG_FILE, line + '\n');
|
||||
}
|
||||
|
||||
// Generate Keys
|
||||
const bytesToHex = (bytes) => Buffer.from(bytes).toString('hex');
|
||||
const EPHEMERAL_SK_BYTES = generateSecretKey();
|
||||
const EPHEMERAL_SK = bytesToHex(EPHEMERAL_SK_BYTES);
|
||||
const EPHEMERAL_PK = getPublicKey(EPHEMERAL_SK_BYTES);
|
||||
|
||||
async function startHttpServer() {
|
||||
log('Starting HTTP Server...');
|
||||
const serve = spawn('npx', ['serve', '.', '-p', String(HTTP_PORT)], {
|
||||
stdio: 'ignore',
|
||||
shell: true
|
||||
});
|
||||
|
||||
const maxRetries = 20;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await fetch(HTTP_URL);
|
||||
log('HTTP Server is ready.');
|
||||
return serve;
|
||||
} catch (e) {
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
}
|
||||
}
|
||||
serve.kill();
|
||||
throw new Error('HTTP Server failed to start within timeout.');
|
||||
}
|
||||
|
||||
async function runSmokeTest() {
|
||||
// Ensure artifacts dir
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) {
|
||||
fs.mkdirSync(ARTIFACTS_DIR);
|
||||
}
|
||||
fs.writeFileSync(LOG_FILE, ''); // Clear log
|
||||
|
||||
let relayServer;
|
||||
let httpServer;
|
||||
let browser;
|
||||
let nodeClient;
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
log('--- Smoke Test Started ---');
|
||||
log(`Ephemeral Pubkey: ${EPHEMERAL_PK}`);
|
||||
|
||||
// 1. Start Infrastructure
|
||||
relayServer = startRelay(RELAY_PORT);
|
||||
httpServer = await startHttpServer();
|
||||
|
||||
// 2. Initialize Node Client
|
||||
log('Initializing Node Client...');
|
||||
nodeClient = new NostrClient();
|
||||
nodeClient.relays = [RELAY_URL];
|
||||
nodeClient.writeRelays = [RELAY_URL];
|
||||
nodeClient.readRelays = [RELAY_URL];
|
||||
|
||||
await nodeClient.registerPrivateKeySigner({ privateKey: EPHEMERAL_SK });
|
||||
await nodeClient.init();
|
||||
log('Node Client connected.');
|
||||
|
||||
// 3. Publish Video Event (Node)
|
||||
log('Node: Publishing Video Post...');
|
||||
const videoEventTemplate = buildVideoPostEvent({
|
||||
pubkey: EPHEMERAL_PK,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
dTagValue: `smoke-test-${Date.now()}`,
|
||||
content: {
|
||||
version: 3,
|
||||
title: 'Smoke Test Video',
|
||||
url: 'https://example.com/video.mp4',
|
||||
description: 'A video to verify feed rendering.',
|
||||
mode: 'live',
|
||||
videoRootId: `smoke-root-${Date.now()}`
|
||||
}
|
||||
});
|
||||
|
||||
const { signedEvent: publishedVideo } = await nodeClient.signAndPublishEvent(videoEventTemplate, {
|
||||
context: 'smoke-video'
|
||||
});
|
||||
log(`Node: Published Video ID: ${publishedVideo.id}`);
|
||||
|
||||
// 4. Publish DM (Node -> Self)
|
||||
log('Node: Publishing Encrypted DM...');
|
||||
const dmMessage = "Smoke Test Secret Message";
|
||||
const signer = await nodeClient.ensureActiveSignerForPubkey(EPHEMERAL_PK);
|
||||
const ciphertext = await signer.nip04Encrypt(EPHEMERAL_PK, dmMessage);
|
||||
|
||||
const dmTemplate = buildLegacyDirectMessageEvent({
|
||||
pubkey: EPHEMERAL_PK,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
recipientPubkey: EPHEMERAL_PK,
|
||||
ciphertext: ciphertext
|
||||
});
|
||||
|
||||
const { signedEvent: publishedDM } = await nodeClient.signAndPublishEvent(dmTemplate, {
|
||||
context: 'smoke-dm'
|
||||
});
|
||||
log(`Node: Published DM ID: ${publishedDM.id}`);
|
||||
|
||||
// 5. Browser Test (Playwright)
|
||||
log('Browser: Launching...');
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
|
||||
// Inject LocalStorage for Auto-Login and Relay Config
|
||||
const savedProfiles = {
|
||||
entries: [{ pubkey: EPHEMERAL_PK, authType: "nip07" }],
|
||||
activePubkey: EPHEMERAL_PK,
|
||||
version: 1
|
||||
};
|
||||
const relayList = [{ url: RELAY_URL, mode: "both" }];
|
||||
|
||||
// Note: Key format derived from investigation: bitvid:profile:<pubkey>:relayList:v1
|
||||
const relayStorageKey = `bitvid:profile:${EPHEMERAL_PK}:relayList:v1`;
|
||||
|
||||
await context.addInitScript(({ profiles, relays, relayKey }) => {
|
||||
// Disable Whitelist Mode to allow fresh pubkey login
|
||||
window.localStorage.setItem('bitvid_admin_whitelist_mode', 'false');
|
||||
|
||||
window.localStorage.setItem('bitvid:savedProfiles:v1', JSON.stringify(profiles));
|
||||
window.localStorage.setItem(relayKey, JSON.stringify(relays));
|
||||
// Mock NIP-07 to avoid errors if app tries to use it (though we won't sign in browser)
|
||||
window.nostr = {
|
||||
getPublicKey: async () => profiles.activePubkey,
|
||||
signEvent: async () => { throw new Error("Mock NIP-07 cannot sign"); }
|
||||
};
|
||||
}, { profiles: savedProfiles, relays: relayList, relayKey: relayStorageKey });
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
log(`Browser: Navigating to ${HTTP_URL}...`);
|
||||
|
||||
// Capture Console
|
||||
page.on('console', msg => log(`[Browser Console] ${msg.type()}: ${msg.text()}`));
|
||||
page.on('pageerror', err => log(`[Browser Error] ${err.message}`));
|
||||
|
||||
await page.goto(HTTP_URL);
|
||||
|
||||
// Verify Login (Profile Button presence)
|
||||
log('Browser: Verifying Login...');
|
||||
// #profileButton might be hidden initially, wait for it
|
||||
try {
|
||||
await page.waitForSelector('#profileButton', { state: 'visible', timeout: 15000 });
|
||||
log('Browser: Login verified (Profile button visible).');
|
||||
} catch (e) {
|
||||
log('Browser: Login verification failed (Profile button not found). Dumping localStorage...');
|
||||
const storage = await page.evaluate(() => JSON.stringify(localStorage));
|
||||
log(`[Browser Storage] ${storage}`);
|
||||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'smoke-fail-login.png') });
|
||||
}
|
||||
|
||||
// Verify Feed (Video Card presence)
|
||||
log('Browser: Verifying Video in Feed...');
|
||||
// Look for the video title
|
||||
try {
|
||||
// Wait longer for feed to load (relays might be slow)
|
||||
await page.waitForSelector(`text=Smoke Test Video`, { timeout: 60000 });
|
||||
log('Browser: Video found in feed!');
|
||||
} catch (e) {
|
||||
log('Browser: Video NOT found in feed. Taking screenshot...');
|
||||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'smoke-fail-feed.png') });
|
||||
|
||||
// Debug: Check relays
|
||||
const relayState = await page.evaluate(() => {
|
||||
// Try to access global nostrClient if exposed, or check localStorage again
|
||||
return {
|
||||
localStorage: JSON.stringify(localStorage),
|
||||
// If we can't access client directly, we rely on logs
|
||||
};
|
||||
});
|
||||
log(`[Browser Debug] Storage: ${relayState.localStorage}`);
|
||||
|
||||
throw new Error('Browser: Video NOT found in feed.');
|
||||
}
|
||||
|
||||
// Screenshot
|
||||
await page.screenshot({ path: SCREENSHOT_FILE });
|
||||
log(`Browser: Screenshot saved to ${SCREENSHOT_FILE}`);
|
||||
|
||||
// 6. Node Verification (DM Decrypt)
|
||||
log('Node: Verifying DM Decryption...');
|
||||
// Fetch back
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const fetchedDM = await nodeClient.fetchRawEventById(publishedDM.id);
|
||||
if (!fetchedDM) {
|
||||
throw new Error('Node: Failed to fetch DM back.');
|
||||
}
|
||||
|
||||
const decryptContext = await nodeClient.buildDmDecryptContext(EPHEMERAL_PK);
|
||||
const decryptionResult = await decryptDM(fetchedDM, decryptContext);
|
||||
|
||||
if (!decryptionResult.ok) {
|
||||
log(`Node: Decryption failed: ${JSON.stringify(decryptionResult.errors)}`);
|
||||
throw new Error('Node: Decryption failed.');
|
||||
}
|
||||
|
||||
if (decryptionResult.plaintext !== dmMessage) {
|
||||
throw new Error(`Node: Decryption mismatch. Got "${decryptionResult.plaintext}"`);
|
||||
}
|
||||
log(`Node: DM Decrypted successfully: "${decryptionResult.plaintext}"`);
|
||||
|
||||
log('--- Smoke Test PASSED ---');
|
||||
|
||||
// Generate JSON Summary
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "success",
|
||||
details: {
|
||||
videoEventId: publishedVideo.id,
|
||||
dmEventId: publishedDM.id,
|
||||
ephemeralPubkey: EPHEMERAL_PK
|
||||
}
|
||||
};
|
||||
const summaryFile = path.join(ARTIFACTS_DIR, `smoke-summary-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.json`);
|
||||
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
|
||||
log(`Summary saved to ${summaryFile}`);
|
||||
|
||||
} catch (err) {
|
||||
log(`--- Smoke Test FAILED: ${err.message} ---`);
|
||||
if (err.stack) log(err.stack);
|
||||
exitCode = 1;
|
||||
} finally {
|
||||
// Cleanup
|
||||
log('Cleaning up...');
|
||||
if (nodeClient && nodeClient.pool) {
|
||||
// Close pool connections
|
||||
if (typeof nodeClient.pool.close === 'function') {
|
||||
nodeClient.pool.close(nodeClient.relays);
|
||||
}
|
||||
}
|
||||
if (browser) await browser.close();
|
||||
if (httpServer) httpServer.kill();
|
||||
if (relayServer) await relayServer.close();
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
runSmokeTest();
|
||||
@@ -129,6 +129,10 @@ async function reviewPR(prNumber, baseRef) {
|
||||
hasFailures = true;
|
||||
commentBody += '### ⚠️ Lint Warnings/Errors\n\n';
|
||||
commentBody += '```\n' + lintRes.stdout + '\n' + lintRes.stderr + '\n```\n\n';
|
||||
|
||||
if (lintRes.stdout.includes('stylelint') || lintRes.stderr.includes('stylelint')) {
|
||||
commentBody += '> **💡 Tip:** Try running `npm run lint:css -- --fix` to automatically resolve some style issues.\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Unit Tests
|
||||
@@ -138,6 +142,19 @@ async function reviewPR(prNumber, baseRef) {
|
||||
hasFailures = true;
|
||||
commentBody += '### ❌ Test Failures\n\n';
|
||||
commentBody += '```\n' + testRes.stdout + '\n' + testRes.stderr + '\n```\n\n';
|
||||
|
||||
// Extract failing test files
|
||||
const failingTests = [];
|
||||
const failureRegex = /✖\s+(tests\/[\w\-\.\/]+)\s+failed with exit code 1/g;
|
||||
const testOutput = testRes.stdout + '\n' + testRes.stderr;
|
||||
let match;
|
||||
while ((match = failureRegex.exec(testOutput)) !== null) {
|
||||
failingTests.push(match[1]);
|
||||
}
|
||||
|
||||
if (failingTests.length > 0) {
|
||||
commentBody += `> **Suggested areas to inspect:** ${failingTests.map(f => `\`${f}\``).join(', ')}\n`;
|
||||
}
|
||||
commentBody += '> **Suggestion:** Inspect the stack trace above. Run `npm run test:unit` locally to reproduce.\n\n';
|
||||
} else {
|
||||
commentBody += '### ✅ Tests Passed\n\nAll unit tests passed.\n\n';
|
||||
@@ -159,7 +176,10 @@ async function reviewPR(prNumber, baseRef) {
|
||||
if (sensitiveChanges.length > 0) {
|
||||
commentBody += '### 🛡️ Guardrails\n\n';
|
||||
commentBody += 'This PR modifies sensitive files. **Security and Protocol Review Required.**\n';
|
||||
|
||||
// Explicit flags for CI/bots to pick up
|
||||
commentBody += '`requires-security-review` `requires-protocol-review`\n\n';
|
||||
|
||||
commentBody += '<details><summary>Sensitive Files Touched</summary>\n\n';
|
||||
sensitiveChanges.forEach(f => commentBody += `- ${f}\n`);
|
||||
commentBody += '\n</details>\n\n';
|
||||
|
||||
@@ -42,7 +42,7 @@ const VALUE_ALLOWLIST = new Map([
|
||||
]);
|
||||
|
||||
const MARKUP_EXTENSIONS = new Set([".html", ".md", ".mdx", ".markdown", ".njk", ".nunjucks"]);
|
||||
const MARKUP_IGNORED_DIRECTORIES = new Set(["node_modules", ".git"]);
|
||||
const MARKUP_IGNORED_DIRECTORIES = new Set(["node_modules", ".git", "issues", "scripts"]);
|
||||
const CLASS_ATTRIBUTE_PATTERN = /class\s*=\s*(["'])(.*?)\1/gis;
|
||||
const BRACKET_UTILITY_PATTERN = /\[[^\]]+\]/g;
|
||||
const CHECK_MODES = new Set(["all", "tokens", "brackets"]);
|
||||
|
||||
@@ -32,7 +32,8 @@ const IGNORED_GLOBS = [
|
||||
'tests/visual/embed-layout.spec.ts',
|
||||
'ai/**',
|
||||
'**/*.ai',
|
||||
'releases/**'
|
||||
'issues/**',
|
||||
'scripts/agent/**'
|
||||
];
|
||||
|
||||
const gitArgs = [
|
||||
|
||||
@@ -26,6 +26,8 @@ const IGNORED_DIRS = new Set([
|
||||
"logs",
|
||||
"playwright-report",
|
||||
"test-results",
|
||||
"issues",
|
||||
"scripts",
|
||||
]);
|
||||
|
||||
const DIST_SCAN_PREFIXES = new Set([
|
||||
|
||||
@@ -45,6 +45,9 @@ test.beforeEach(async () => {
|
||||
restoreNostrClient();
|
||||
restoreRelayManager();
|
||||
window.nostr = originalWindowNostr;
|
||||
if (nostrClient.extensionPermissionCache) {
|
||||
nostrClient.extensionPermissionCache.clear();
|
||||
}
|
||||
|
||||
// Setup default mock signer to prevent auto-save errors
|
||||
setActiveSigner({
|
||||
@@ -256,7 +259,7 @@ test(
|
||||
let fetchCalls = 0;
|
||||
nostrClient.fetchListIncrementally = async () => {
|
||||
fetchCalls += 1;
|
||||
return fetchCalls <= 4 ? [event] : [];
|
||||
return [event]; // ALWAYS return the event
|
||||
};
|
||||
nostrClient.relays = relayUrls;
|
||||
nostrClient.writeRelays = relayUrls;
|
||||
@@ -266,9 +269,9 @@ test(
|
||||
// Create a mock window.nostr that properly simulates extension behavior.
|
||||
// Specifically, enable() needs to exist so nostrClient detects it as an extension.
|
||||
window.nostr = {
|
||||
enable: async () => {},
|
||||
getPublicKey: async () => pubkey,
|
||||
enable: async () => Promise.resolve(true),
|
||||
nip04: {
|
||||
encrypt: async () => "cipher",
|
||||
decrypt: async () => {
|
||||
if (!permissionState.enabled) throw new Error("permission denied");
|
||||
decryptCalls.push("nip04");
|
||||
@@ -279,6 +282,10 @@ test(
|
||||
});
|
||||
},
|
||||
},
|
||||
nip44: {
|
||||
encrypt: async () => "cipher",
|
||||
decrypt: async () => "plaintext",
|
||||
}
|
||||
};
|
||||
|
||||
clearActiveSigner();
|
||||
@@ -573,6 +580,7 @@ test(
|
||||
|
||||
// Mock window.nostr
|
||||
window.nostr = {
|
||||
enable: async () => Promise.resolve(true),
|
||||
nip04: {
|
||||
decrypt: async (pk, ciphertext) => {
|
||||
if (pk !== pubkey) throw new Error("Wrong pubkey");
|
||||
@@ -621,6 +629,7 @@ test(
|
||||
|
||||
// Mock window.nostr
|
||||
window.nostr = {
|
||||
enable: async () => Promise.resolve(true),
|
||||
nip04: {
|
||||
decrypt: async (pk, ciphertext) => {
|
||||
if (pk !== pubkey) throw new Error("Wrong pubkey");
|
||||
|
||||
@@ -165,31 +165,25 @@ describe("NIP-07 Login Permissions", () => {
|
||||
const pubkey = result.pubkey;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
assert.ok(env.enableCalls.length >= 1, "extension.enable should be invoked");
|
||||
assert.ok(
|
||||
nostrClient.extensionPermissionCache.has("nip04.encrypt"),
|
||||
"nip04.encrypt permission should be tracked as granted",
|
||||
);
|
||||
assert.ok(
|
||||
nostrClient.extensionPermissionCache.has("nip04.decrypt"),
|
||||
"nip04.decrypt permission should be tracked as granted",
|
||||
);
|
||||
assert.ok(
|
||||
nostrClient.extensionPermissionCache.has("nip44.encrypt"),
|
||||
"nip44.encrypt permission should be tracked as granted",
|
||||
);
|
||||
assert.ok(
|
||||
nostrClient.extensionPermissionCache.has("nip44.decrypt"),
|
||||
"nip44.decrypt permission should be tracked as granted",
|
||||
);
|
||||
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
|
||||
|
||||
const permissionsToCheck = [
|
||||
"nip04.encrypt",
|
||||
"nip04.decrypt",
|
||||
"nip44.encrypt",
|
||||
"nip44.decrypt",
|
||||
...EXPECTED_ENCRYPTION_PERMISSIONS
|
||||
];
|
||||
|
||||
for (const method of permissionsToCheck) {
|
||||
assert.ok(
|
||||
nostrClient.extensionPermissionCache.has(method),
|
||||
`${method} permission should be tracked as granted`,
|
||||
`${method} permission should be tracked as granted`
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
nostrClient.extensionPermissionCache.has("sign_event"),
|
||||
"sign_event permission should be tracked as granted",
|
||||
"sign_event permission should be tracked as granted"
|
||||
);
|
||||
} finally {
|
||||
env.restore();
|
||||
@@ -220,18 +214,18 @@ describe("NIP-07 Login Permissions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await nip07Provider.login({ nostrClient });
|
||||
const pubkey = result.pubkey;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
await nip07Provider.login({ nostrClient });
|
||||
|
||||
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
|
||||
assert.ok(
|
||||
nostrClient.extensionPermissionCache.has(method),
|
||||
`nostrClient should track ${method} after login`,
|
||||
`nostrClient should track ${method} after login`
|
||||
);
|
||||
}
|
||||
|
||||
const storedRaw = localStorage.getItem(PERMISSIONS_STORAGE_KEY);
|
||||
assert.ok(storedRaw, "extension permissions should persist to localStorage");
|
||||
|
||||
let storedMethods = [];
|
||||
try {
|
||||
const parsed = JSON.parse(storedRaw);
|
||||
@@ -252,9 +246,10 @@ describe("NIP-07 Login Permissions", () => {
|
||||
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
|
||||
assert.ok(
|
||||
storedSet.has(method),
|
||||
`stored permissions should include ${method}`,
|
||||
`stored permissions should include ${method}`
|
||||
);
|
||||
}
|
||||
|
||||
const baselineEnableCalls = env.enableCalls.length;
|
||||
const freshClient = new NostrClient();
|
||||
freshClient.pubkey = HEX_PUBKEY;
|
||||
@@ -262,7 +257,7 @@ describe("NIP-07 Login Permissions", () => {
|
||||
for (const method of EXPECTED_ENCRYPTION_PERMISSIONS) {
|
||||
assert.ok(
|
||||
freshClient.extensionPermissionCache.has(method),
|
||||
`fresh client should hydrate ${method} from storage`,
|
||||
`fresh client should hydrate ${method} from storage`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,7 +269,7 @@ describe("NIP-07 Login Permissions", () => {
|
||||
assert.equal(
|
||||
env.enableCalls.length,
|
||||
baselineEnableCalls,
|
||||
`cached permissions should prevent extra enable() calls for ${method}`,
|
||||
`cached permissions should prevent extra enable() calls for ${method}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -288,7 +283,7 @@ describe("NIP-07 Login Permissions", () => {
|
||||
assert.equal(
|
||||
env.enableCalls.length,
|
||||
baselineEnableCalls,
|
||||
"decrypt calls should not trigger additional enable() prompts",
|
||||
"decrypt calls should not trigger additional enable() prompts"
|
||||
);
|
||||
assert.equal(decryptCalls.nip04, 1);
|
||||
assert.equal(decryptCalls.nip44, 1);
|
||||
@@ -322,37 +317,27 @@ describe("NIP-07 Login Permissions", () => {
|
||||
const result = await nip07Provider.login({ nostrClient });
|
||||
const pubkey = result.pubkey;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
// With Structured -> String -> Plain order, this succeeds at step 2 (String).
|
||||
// The login flow may perform multiple permission requests (e.g. core -> full).
|
||||
// We expect at least one successful sequence (2 calls).
|
||||
assert.ok(env.enableCalls.length >= 2, "should retry with alternate payloads");
|
||||
|
||||
// Check the first sequence (core permissions)
|
||||
const [objectCall, stringCall] = env.enableCalls;
|
||||
assert.ok(Array.isArray(objectCall?.permissions));
|
||||
assert.equal(
|
||||
typeof objectCall.permissions[0],
|
||||
"object",
|
||||
"structured permission request should be attempted first",
|
||||
"structured permission request should be attempted first"
|
||||
);
|
||||
assert.ok(Array.isArray(stringCall?.permissions));
|
||||
assert.equal(
|
||||
typeof stringCall.permissions[0],
|
||||
"string",
|
||||
"string-based permissions should be attempted after structured payloads",
|
||||
"string-based permissions should be attempted after structured payloads"
|
||||
);
|
||||
|
||||
// If we have more calls (full permissions upgrade), verify they follow the same pattern
|
||||
if (env.enableCalls.length > 2) {
|
||||
// The 3rd call should be a new structured attempt for full permissions
|
||||
const upgradeObjectCall = env.enableCalls[2];
|
||||
// The 4th call (if 3rd failed) should be string
|
||||
const upgradeStringCall = env.enableCalls[3];
|
||||
|
||||
if (upgradeObjectCall) {
|
||||
assert.ok(Array.isArray(upgradeObjectCall.permissions), "upgrade attempt should use permissions");
|
||||
}
|
||||
// We don't strictly assert `plainCall` is undefined because a 2nd pass might happen
|
||||
}
|
||||
} finally {
|
||||
env.restore();
|
||||
@@ -376,21 +361,14 @@ describe("NIP-07 Login Permissions", () => {
|
||||
const result = await nip07Provider.login({ nostrClient });
|
||||
const pubkey = result.pubkey;
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
// With Structured -> String -> Plain order, we expect at least 3 calls for the first success:
|
||||
// 1. Structured (rejected)
|
||||
// 2. String (rejected)
|
||||
// 3. Plain (accepted)
|
||||
// If login does a second pass (upgrade), it might repeat this sequence.
|
||||
assert.ok(
|
||||
env.enableCalls.length >= 3,
|
||||
"should eventually succeed with plain enable()",
|
||||
"should eventually succeed with plain enable()"
|
||||
);
|
||||
|
||||
// Verify the first sequence structure
|
||||
const [objectCall, stringCall, plainCall] = env.enableCalls;
|
||||
assert.ok(objectCall !== undefined);
|
||||
assert.ok(stringCall !== undefined);
|
||||
// plainCall is the one that succeeded (called with undefined/no args)
|
||||
assert.equal(plainCall, undefined, "successful attempt should use plain enable()");
|
||||
} finally {
|
||||
env.restore();
|
||||
@@ -429,11 +407,8 @@ describe("NIP-07 Login Permissions", () => {
|
||||
assert.equal(pubkey, HEX_PUBKEY);
|
||||
assert.ok(
|
||||
duration < 1000,
|
||||
`login should fall back quickly from stalled enable payloads (duration: ${duration}ms)`,
|
||||
`login should fall back quickly from stalled enable payloads (duration: ${duration}ms)`
|
||||
);
|
||||
// Structured (stalls) -> String (resolves)
|
||||
// Plain is skipped because String resolved.
|
||||
// Login might do this twice (Core then Full), so 2 or 4 calls.
|
||||
assert.ok(env.enableCalls.length >= 2, "should attempt structured (stalls) and then string payload");
|
||||
|
||||
const [objectCall, stringCall] = env.enableCalls;
|
||||
@@ -473,13 +448,13 @@ describe("NIP-07 Login Permissions", () => {
|
||||
assert.equal(result.pubkey, HEX_PUBKEY);
|
||||
assert.ok(
|
||||
duration < 200,
|
||||
`login should resolve before deferred permission grants (duration: ${duration}ms)`,
|
||||
`login should resolve before deferred permission grants (duration: ${duration}ms)`
|
||||
);
|
||||
assert.ok(completionPromise, "completionPromise should be captured");
|
||||
await assert.rejects(
|
||||
completionPromise,
|
||||
/permission denied/,
|
||||
"deferred permission grants should still reject",
|
||||
"deferred permission grants should still reject"
|
||||
);
|
||||
} finally {
|
||||
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
||||
|
||||
@@ -45,14 +45,14 @@ describe("Nostr Private Key Signer", () => {
|
||||
const outboundCipher = await signer.nip04Encrypt(recipientPubkey, message);
|
||||
assert.equal(typeof outboundCipher, "string");
|
||||
const decryptedByRecipient = await nostrTools.nip04.decrypt(
|
||||
recipientPrivateHex,
|
||||
recipientKeyBytes,
|
||||
signer.pubkey,
|
||||
outboundCipher,
|
||||
);
|
||||
assert.equal(decryptedByRecipient, message);
|
||||
|
||||
const inboundCipher = await nostrTools.nip04.encrypt(
|
||||
recipientPrivateHex,
|
||||
recipientKeyBytes,
|
||||
signer.pubkey,
|
||||
message,
|
||||
);
|
||||
|
||||
@@ -92,10 +92,9 @@ test("sendDirectMessage succeeds with private key signer and no extension", asyn
|
||||
nostrClient.readRelays = [RELAY_URL];
|
||||
nostrClient.writeRelays = [RELAY_URL];
|
||||
|
||||
const senderPrivateKey = canonicalTools.utils.bytesToHex(
|
||||
canonicalTools.generateSecretKey(),
|
||||
);
|
||||
const senderPubkey = canonicalTools.getPublicKey(senderPrivateKey);
|
||||
const senderSecret = canonicalTools.generateSecretKey();
|
||||
const senderPrivateKey = canonicalTools.utils.bytesToHex(senderSecret);
|
||||
const senderPubkey = canonicalTools.getPublicKey(senderSecret);
|
||||
const recipientSecret = canonicalTools.generateSecretKey();
|
||||
const recipientPrivateKey = canonicalTools.utils.bytesToHex(recipientSecret);
|
||||
const recipientPubkey = canonicalTools.getPublicKey(recipientSecret);
|
||||
@@ -112,7 +111,7 @@ test("sendDirectMessage succeeds with private key signer and no extension", asyn
|
||||
const originalNip44 = signer?.nip44Encrypt;
|
||||
if (signer) {
|
||||
signer.signEvent = (event) => {
|
||||
const finalized = canonicalTools.finalizeEvent(event, senderPrivateKey);
|
||||
const finalized = canonicalTools.finalizeEvent(event, senderSecret);
|
||||
return { ...event, id: finalized.id, sig: finalized.sig };
|
||||
};
|
||||
delete signer.nip04Encrypt;
|
||||
@@ -133,7 +132,7 @@ test("sendDirectMessage succeeds with private key signer and no extension", asyn
|
||||
assert.notEqual(event.content, "", "ciphertext should not be empty");
|
||||
|
||||
const decrypted = await canonicalTools.nip04.decrypt(
|
||||
recipientPrivateKey,
|
||||
recipientSecret,
|
||||
senderPubkey,
|
||||
event.content,
|
||||
);
|
||||
|
||||
@@ -151,24 +151,24 @@ async function setupDmScenario() {
|
||||
const senderPrivateKey = nostrTools.utils.bytesToHex(senderSecret);
|
||||
const receiverPrivateKey = nostrTools.utils.bytesToHex(receiverSecret);
|
||||
|
||||
const senderPubkey = nostrTools.getPublicKey(senderPrivateKey);
|
||||
const receiverPubkey = nostrTools.getPublicKey(receiverPrivateKey);
|
||||
const senderPubkey = nostrTools.getPublicKey(senderSecret);
|
||||
const receiverPubkey = nostrTools.getPublicKey(receiverSecret);
|
||||
|
||||
const createSigner = (privateKey, pubkey) => ({
|
||||
const createSigner = (privateKeyBytes, pubkey) => ({
|
||||
type: "test",
|
||||
pubkey,
|
||||
signEvent: (event) => {
|
||||
const finalized = nostrTools.finalizeEvent(event, privateKey);
|
||||
const finalized = nostrTools.finalizeEvent(event, privateKeyBytes);
|
||||
return { ...event, id: finalized.id, sig: finalized.sig };
|
||||
},
|
||||
nip04Encrypt: (target, plaintext) =>
|
||||
nostrTools.nip04.encrypt(privateKey, target, plaintext),
|
||||
nostrTools.nip04.encrypt(privateKeyBytes, target, plaintext),
|
||||
nip04Decrypt: (target, ciphertext) =>
|
||||
nostrTools.nip04.decrypt(privateKey, target, ciphertext),
|
||||
nostrTools.nip04.decrypt(privateKeyBytes, target, ciphertext),
|
||||
});
|
||||
|
||||
const senderSigner = createSigner(senderPrivateKey, senderPubkey);
|
||||
const receiverSigner = createSigner(receiverPrivateKey, receiverPubkey);
|
||||
const senderSigner = createSigner(senderSecret, senderPubkey);
|
||||
const receiverSigner = createSigner(receiverSecret, receiverPubkey);
|
||||
|
||||
const relayPool = new FakeRelayPool();
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ test("Nip46RpcClient encrypts payloads with nip44 conversation keys", async () =
|
||||
|
||||
const clientSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remoteSecret = utils.bytesToHex(generateSecretKey());
|
||||
const clientPublicKey = getPublicKey(clientSecret);
|
||||
const remotePubkey = getPublicKey(remoteSecret);
|
||||
const clientPublicKey = getPublicKey(utils.hexToBytes(clientSecret));
|
||||
const remotePubkey = getPublicKey(utils.hexToBytes(remoteSecret));
|
||||
|
||||
const rpcClient = new Nip46RpcClient({
|
||||
nostrClient: { relays: [] },
|
||||
@@ -43,8 +43,8 @@ test("Nip46RpcClient encrypts payloads with nip44 conversation keys", async () =
|
||||
|
||||
const conversationKey =
|
||||
typeof nip44.v2.getConversationKey === "function"
|
||||
? nip44.v2.getConversationKey(clientSecret, remotePubkey)
|
||||
: nip44.v2.utils.getConversationKey(clientSecret, remotePubkey);
|
||||
? nip44.v2.getConversationKey(utils.hexToBytes(clientSecret), remotePubkey)
|
||||
: nip44.v2.utils.getConversationKey(utils.hexToBytes(clientSecret), remotePubkey);
|
||||
const decryptedWithTools = nip44.v2.decrypt(ciphertext, conversationKey);
|
||||
assert.equal(
|
||||
decryptedWithTools,
|
||||
@@ -71,13 +71,13 @@ test("decryptNip46PayloadWithKeys handles nip44.v2 ciphertext", async () => {
|
||||
|
||||
const clientSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remoteSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remotePubkey = getPublicKey(remoteSecret);
|
||||
const remotePubkey = getPublicKey(utils.hexToBytes(remoteSecret));
|
||||
|
||||
const serialized = JSON.stringify({ message: "hello", count: 42 });
|
||||
const conversationKey =
|
||||
typeof nip44.v2.getConversationKey === "function"
|
||||
? nip44.v2.getConversationKey(clientSecret, remotePubkey)
|
||||
: nip44.v2.utils.getConversationKey(clientSecret, remotePubkey);
|
||||
? nip44.v2.getConversationKey(utils.hexToBytes(clientSecret), remotePubkey)
|
||||
: nip44.v2.utils.getConversationKey(utils.hexToBytes(clientSecret), remotePubkey);
|
||||
const ciphertext = nip44.v2.encrypt(serialized, conversationKey);
|
||||
|
||||
const { plaintext, algorithm } = await decryptNip46PayloadWithKeys(
|
||||
@@ -104,13 +104,13 @@ test("decryptNip46PayloadWithKeys coerces structured handshake payloads", async
|
||||
|
||||
const clientSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remoteSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remotePubkey = getPublicKey(remoteSecret);
|
||||
const remotePubkey = getPublicKey(utils.hexToBytes(remoteSecret));
|
||||
|
||||
const serialized = JSON.stringify({ message: "hello", count: 42 });
|
||||
const conversationKey =
|
||||
typeof nip44.v2.getConversationKey === "function"
|
||||
? nip44.v2.getConversationKey(clientSecret, remotePubkey)
|
||||
: nip44.v2.utils.getConversationKey(clientSecret, remotePubkey);
|
||||
? nip44.v2.getConversationKey(utils.hexToBytes(clientSecret), remotePubkey)
|
||||
: nip44.v2.utils.getConversationKey(utils.hexToBytes(clientSecret), remotePubkey);
|
||||
const ciphertext = nip44.v2.encrypt(serialized, conversationKey);
|
||||
const [ciphertextPart, nonce] = ciphertext.split("\n");
|
||||
|
||||
@@ -154,13 +154,13 @@ test("decryptNip46PayloadWithKeys decodes buffer-based handshake payloads", asyn
|
||||
|
||||
const clientSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remoteSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remotePubkey = getPublicKey(remoteSecret);
|
||||
const remotePubkey = getPublicKey(utils.hexToBytes(remoteSecret));
|
||||
|
||||
const serialized = JSON.stringify({ message: "hello", count: 42 });
|
||||
const conversationKey =
|
||||
typeof nip44.v2.getConversationKey === "function"
|
||||
? nip44.v2.getConversationKey(clientSecret, remotePubkey)
|
||||
: nip44.v2.utils.getConversationKey(clientSecret, remotePubkey);
|
||||
? nip44.v2.getConversationKey(utils.hexToBytes(clientSecret), remotePubkey)
|
||||
: nip44.v2.utils.getConversationKey(utils.hexToBytes(clientSecret), remotePubkey);
|
||||
const ciphertext = nip44.v2.encrypt(serialized, conversationKey);
|
||||
const [ciphertextPart, nonce] = ciphertext.split("\n");
|
||||
const ciphertextBuffer = Buffer.from(ciphertext, "utf8");
|
||||
@@ -197,10 +197,10 @@ test("decryptNip46PayloadWithKeys supports nip04-style ciphertext wrappers", asy
|
||||
|
||||
const clientSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remoteSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remotePubkey = getPublicKey(remoteSecret);
|
||||
const remotePubkey = getPublicKey(utils.hexToBytes(remoteSecret));
|
||||
|
||||
const serialized = JSON.stringify({ message: "hello", count: 42 });
|
||||
const ciphertext = await nip04.encrypt(clientSecret, remotePubkey, serialized);
|
||||
const ciphertext = await nip04.encrypt(utils.hexToBytes(clientSecret), remotePubkey, serialized);
|
||||
const [ciphertextPart, iv] = ciphertext.split("?iv=");
|
||||
|
||||
const structuredPayload = {
|
||||
@@ -254,8 +254,8 @@ test("parseNip46ConnectionString handles remote signer key hints", async () => {
|
||||
const signerSecret = utils.bytesToHex(generateSecretKey());
|
||||
const userSecret = utils.bytesToHex(generateSecretKey());
|
||||
|
||||
const signerPubkey = getPublicKey(signerSecret).toLowerCase();
|
||||
const userPubkey = getPublicKey(userSecret).toLowerCase();
|
||||
const signerPubkey = getPublicKey(utils.hexToBytes(signerSecret)).toLowerCase();
|
||||
const userPubkey = getPublicKey(utils.hexToBytes(userSecret)).toLowerCase();
|
||||
|
||||
const signerNpub = nip19.npubEncode(signerPubkey);
|
||||
const userNpub = nip19.npubEncode(userPubkey);
|
||||
@@ -295,13 +295,13 @@ test(
|
||||
const remoteSignerSecret = utils.bytesToHex(generateSecretKey());
|
||||
const userSecret = utils.bytesToHex(generateSecretKey());
|
||||
|
||||
const remoteSignerPubkey = getPublicKey(remoteSignerSecret).toLowerCase();
|
||||
const userPubkey = getPublicKey(userSecret).toLowerCase();
|
||||
const remoteSignerPubkey = getPublicKey(utils.hexToBytes(remoteSignerSecret)).toLowerCase();
|
||||
const userPubkey = getPublicKey(utils.hexToBytes(userSecret)).toLowerCase();
|
||||
|
||||
const conversationKey =
|
||||
typeof nip44.v2.getConversationKey === "function"
|
||||
? nip44.v2.getConversationKey(clientSecret, remoteSignerPubkey)
|
||||
: nip44.v2.utils.getConversationKey(clientSecret, remoteSignerPubkey);
|
||||
? nip44.v2.getConversationKey(utils.hexToBytes(clientSecret), remoteSignerPubkey)
|
||||
: nip44.v2.utils.getConversationKey(utils.hexToBytes(clientSecret), remoteSignerPubkey);
|
||||
|
||||
const payload = JSON.stringify({ id: "ack", result: "ok" });
|
||||
const ciphertext = nip44.v2.encrypt(payload, conversationKey);
|
||||
@@ -333,12 +333,12 @@ test(
|
||||
const { attemptDecryptNip46HandshakePayload } = await loadNip46Module();
|
||||
|
||||
const clientSecret = utils.bytesToHex(generateSecretKey());
|
||||
const clientPubkey = getPublicKey(clientSecret).toLowerCase();
|
||||
const clientPubkey = getPublicKey(utils.hexToBytes(clientSecret)).toLowerCase();
|
||||
const remoteSecret = utils.bytesToHex(generateSecretKey());
|
||||
const remotePubkey = getPublicKey(remoteSecret).toLowerCase();
|
||||
const remotePubkey = getPublicKey(utils.hexToBytes(remoteSecret)).toLowerCase();
|
||||
|
||||
const payload = JSON.stringify({ id: "ack", result: "ack" });
|
||||
const ciphertext = nip04.encrypt(remoteSecret, clientPubkey, payload);
|
||||
const ciphertext = nip04.encrypt(utils.hexToBytes(remoteSecret), clientPubkey, payload);
|
||||
const [ciphertextPart, ivPart] = ciphertext.split("?iv=");
|
||||
|
||||
assert.ok(ivPart, "nip04 encryption should emit an iv segment");
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PORT = 8000;
|
||||
const URL = `http://localhost:${PORT}`;
|
||||
const ARTIFACTS_DIR = path.resolve('artifacts');
|
||||
const LOG_FILE = path.join(ARTIFACTS_DIR, `smoke-${new Date().toISOString().split('T')[0].replace(/-/g, '')}.log`);
|
||||
const SUMMARY_FILE = path.join(ARTIFACTS_DIR, 'smoke-summary.json');
|
||||
|
||||
if (!fs.existsSync(ARTIFACTS_DIR)) {
|
||||
fs.mkdirSync(ARTIFACTS_DIR);
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const line = `[${timestamp}] ${msg}`;
|
||||
console.log(line);
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, line + '\n');
|
||||
} catch (e) {
|
||||
// Ignore log write errors
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
log('Starting smoke test...');
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
// Start Python Server
|
||||
log('Starting local server (python3 -m http.server)...');
|
||||
serverProcess = spawn('python3', ['-m', 'http.server', String(PORT)], {
|
||||
stdio: 'ignore'
|
||||
});
|
||||
|
||||
// Give it time to start
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Launch Browser
|
||||
log('Launching browser...');
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Capture console logs from page
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
log(`[BROWSER ERROR] ${msg.text()}`);
|
||||
} else {
|
||||
// log(`[BROWSER] ${msg.text()}`); // verbose
|
||||
}
|
||||
});
|
||||
page.on('pageerror', err => log(`[BROWSER EXCEPTION] ${err}`));
|
||||
|
||||
log(`Navigating to ${URL}...`);
|
||||
await page.goto(URL);
|
||||
await page.waitForLoadState('networkidle');
|
||||
log('Page loaded.');
|
||||
|
||||
// Inject Test Logic
|
||||
log('Running in-browser smoke test...');
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const logs = [];
|
||||
function browserLog(msg) {
|
||||
console.log(msg);
|
||||
logs.push(msg);
|
||||
}
|
||||
|
||||
try {
|
||||
browserLog('Step 1: Importing Client...');
|
||||
// Note: In browser context, paths are relative to the served root (http://localhost:8000/)
|
||||
const { nostrClient } = await import('./js/nostrClientFacade.js');
|
||||
const { encodeHexToNpub } = await import('./js/nostr/nip46Client.js');
|
||||
|
||||
if (!nostrClient) throw new Error('nostrClient not found');
|
||||
browserLog('nostrClient imported.');
|
||||
|
||||
browserLog('Step 2: Generating Key & Login...');
|
||||
const randomBytes = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(randomBytes);
|
||||
const privateKey = Array.from(randomBytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
const { pubkey } = await nostrClient.registerPrivateKeySigner({ privateKey, persist: false });
|
||||
nostrClient.pubkey = pubkey; // Explicitly set it on the client instance
|
||||
|
||||
if (!nostrClient.pubkey) throw new Error('Login failed: pubkey is null');
|
||||
browserLog(`Logged in as: ${nostrClient.pubkey}`);
|
||||
|
||||
browserLog('Step 3: Connecting to Relays...');
|
||||
const connectResults = await nostrClient.connectToRelays();
|
||||
const successful = connectResults.filter(r => r.success);
|
||||
browserLog(`Connected to ${successful.length} relays.`);
|
||||
|
||||
if (successful.length === 0) {
|
||||
browserLog('Warning: Failed to connect to any relay. Tests depending on network will likely fail or use local cache.');
|
||||
}
|
||||
|
||||
browserLog('Step 4: Publishing Video...');
|
||||
const seriesId = `smoke-test-${Date.now()}-${Math.floor(Math.random()*1000)}`;
|
||||
|
||||
const videoPayload = {
|
||||
title: "Smoke Test Video " + Date.now(),
|
||||
description: "Automated smoke test artifact.",
|
||||
url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
||||
videoRootId: seriesId,
|
||||
mimeType: "video/mp4"
|
||||
};
|
||||
|
||||
const videoEvent = await nostrClient.publishVideo(videoPayload, nostrClient.pubkey);
|
||||
browserLog(`Video published. Event ID: ${videoEvent.id}`);
|
||||
|
||||
browserLog('Step 5: Verifying Video Presence...');
|
||||
const fetched = await nostrClient.getEventById(videoEvent.id);
|
||||
if (!fetched) throw new Error('Failed to retrieve video event from client cache/network.');
|
||||
browserLog('Video event verified.');
|
||||
|
||||
browserLog('Step 6: DM Flow (Send to Self)...');
|
||||
const message = "Smoke test DM " + Date.now();
|
||||
const npub = encodeHexToNpub(nostrClient.pubkey);
|
||||
browserLog(`Sending DM to self (${npub})...`);
|
||||
|
||||
const dmResult = await nostrClient.sendDirectMessage(npub, message);
|
||||
if (!dmResult.ok) throw new Error(`DM send failed: ${dmResult.error} ${JSON.stringify(dmResult.details || {})}`);
|
||||
browserLog('DM sent.');
|
||||
|
||||
browserLog('Step 7: Verifying DM Decryption...');
|
||||
let found = null;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const dms = await nostrClient.listDirectMessages(nostrClient.pubkey, { limit: 10 });
|
||||
found = dms.find(dm => dm.plaintext === message);
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new Error('DM verification failed: Message not found in decrypted list after retries.');
|
||||
}
|
||||
|
||||
browserLog('DM verified and decrypted successfully.');
|
||||
|
||||
return { success: true, logs };
|
||||
} catch (err) {
|
||||
browserLog(`Error: ${err.message}`);
|
||||
return { success: false, error: err.message, stack: err.stack, logs };
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
log('SUCCESS: All smoke tests passed.');
|
||||
fs.writeFileSync(SUMMARY_FILE, JSON.stringify({
|
||||
status: 'success',
|
||||
timestamp: new Date().toISOString(),
|
||||
logs: result.logs
|
||||
}, null, 2));
|
||||
} else {
|
||||
log('FAILURE: Smoke tests failed.');
|
||||
log(`Error: ${result.error}`);
|
||||
fs.writeFileSync(SUMMARY_FILE, JSON.stringify({
|
||||
status: 'failure',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: result.error,
|
||||
stack: result.stack,
|
||||
logs: result.logs
|
||||
}, null, 2));
|
||||
|
||||
const screenshotPath = path.join(ARTIFACTS_DIR, 'smoke-failure.png');
|
||||
await page.screenshot({ path: screenshotPath });
|
||||
log(`Screenshot saved to ${screenshotPath}`);
|
||||
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`CRITICAL ERROR: ${error.message}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (serverProcess) {
|
||||
log('Stopping server...');
|
||||
serverProcess.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in new issue
Block a user