diff --git a/README.md b/README.md
index 32c951a5..6c5bdbfb 100644
--- a/README.md
+++ b/README.md
@@ -431,8 +431,11 @@ artifacts now that the deploy pipeline owns the build step.
preparing a new deployment.
- See [`docs/instance-config.md`](docs/instance-config.md) for a full
reference of every setting and how to tune it.
- - Flip `IS_DEV_MODE` to `false` before shipping production builds. The flag
- flows into `js/config.js` as `isDevMode`, seeds the
+ - `IS_DEV_MODE` and `IS_VERBOSE_DEV_MODE` default to `false` for production.
+ If you need dev logging in a staging or QA build without editing source,
+ inject `window.__BITVID_DEV_MODE_OVERRIDE__` and/or
+ `window.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__` before `js/config.js` loads.
+ The flag flows into `js/config.js` as `isDevMode`, seeds the
`window.__BITVID_DEV_MODE__` global for inline scripts, and gates whether the
dev logging channel emits to the console. See
[`docs/logging.md`](docs/logging.md) for rollout guidance.
diff --git a/config/instance-config.js b/config/instance-config.js
index a391e0a2..c953973d 100644
--- a/config/instance-config.js
+++ b/config/instance-config.js
@@ -17,11 +17,13 @@
* Whether the current deployment should enable developer-centric behavior.
*
* When `true`, bitvid surfaces extra logging, debug helpers, and experimental
- * UI affordances intended for development environments. Set the value to
- * `false` before deploying to production so that end users receive the stable
- * experience. Downstream modules read this flag via `js/config.js`.
+ * UI affordances intended for development environments. Leave the value
+ * `false` for production so end users receive the stable experience; use the
+ * runtime overrides documented in `js/config.js` when you need to toggle dev
+ * mode without editing this file. Downstream modules read this flag via
+ * `js/config.js`.
*/
-export const IS_DEV_MODE = true;
+export const IS_DEV_MODE = false;
/**
* Whether development builds should surface extra verbose diagnostics.
@@ -29,8 +31,10 @@ export const IS_DEV_MODE = true;
* Set this to `true` to keep high-volume development warnings (like COUNT
* request failures) visible in the console. Flip it to `false` when you want
* a quieter dev mode without disabling other development helpers entirely.
+ * Use the runtime override documented in `js/config.js` if you need to toggle
+ * verbosity without editing this file.
*/
-export const IS_VERBOSE_DEV_MODE = true;
+export const IS_VERBOSE_DEV_MODE = false;
/**
* Whether the current deployment should operate in lockdown mode.
diff --git a/docs/instance-config.md b/docs/instance-config.md
index c8997e51..05da1ec3 100644
--- a/docs/instance-config.md
+++ b/docs/instance-config.md
@@ -28,14 +28,30 @@ an operator might change it.
debug affordances. The value is re-exported as `isDevMode` in
`js/config.js` and controls `window.__BITVID_DEV_MODE__` as well as
logger routing.
- - **Why change it:** Set to `false` for production builds so only the stable
- UX and user-facing logging remain.
+ - **Why change it:** Defaults to `false` for production builds. Set to `true`
+ for local development, or use the runtime override described below to
+ toggle dev mode without editing this file.
- **`IS_VERBOSE_DEV_MODE`**
- **What it does:** Enables high-volume diagnostics inside dev mode (for
example, repeated COUNT failures).
- **Why change it:** Keep `true` while debugging noisy workflows and set to
`false` when you want a quieter dev console without disabling dev mode.
+ Defaults to `false` for production.
+
+To override the dev flags at runtime (for example in a staging build) without
+changing `config/instance-config.js`, inject one or both of the following
+globals **before** `js/config.js` loads:
+
+```html
+
+```
+
+The overrides accept boolean values or the strings `"true"` / `"false"` so they
+can be injected at build time.
- **`IS_LOCKDOWN_MODE`**
- **What it does:** Toggles a lockdown posture that limits access and switches
diff --git a/docs/logging.md b/docs/logging.md
index 0f5f81bb..5afd4d18 100644
--- a/docs/logging.md
+++ b/docs/logging.md
@@ -59,10 +59,23 @@ The source of truth lives in `config/instance-config.js`:
- Set `IS_DEV_MODE = true` while developing locally so the dev logger and other
diagnostics are available.
-- Flip `IS_DEV_MODE = false` before shipping a production build. This change
- propagates to `isDevMode` (the module export), `window.__BITVID_DEV_MODE__`,
- and `logger.dev`, silencing development-only messages.
+- Keep `IS_DEV_MODE = false` for production builds. This value propagates to
+ `isDevMode` (the module export), `window.__BITVID_DEV_MODE__`, and
+ `logger.dev`, silencing development-only messages.
Always commit the flag change alongside deployment-ready configuration tweaks.
Forgetting to update the flag leaves verbose traces in user consoles and can
expose experimental behavior intended only for QA environments.
+
+If you need to enable dev logging without editing `config/instance-config.js`,
+inject a runtime override before `js/config.js` loads:
+
+```html
+
+```
+
+Overrides accept boolean values or the strings `"true"` / `"false"` so they can
+be injected by a build pipeline.
diff --git a/js/app.js b/js/app.js
index 50123bd6..9cc27220 100644
--- a/js/app.js
+++ b/js/app.js
@@ -173,6 +173,7 @@ import EngagementController from "./ui/engagementController.js";
import SimilarContentController from "./ui/similarContentController.js";
import UrlHealthController from "./ui/urlHealthController.js";
import VideoModalCommentController from "./ui/videoModalCommentController.js";
+import VideoModalController from "./ui/videoModalController.js";
import TorrentStatusController from "./ui/torrentStatusController.js";
import ShareNostrController from "./ui/shareNostrController.js";
import ModerationActionController from "./services/moderationActionController.js";
@@ -223,6 +224,14 @@ class Application {
setStoredCurrentVideo(value ?? null);
}
+ get activeProfilePubkey() {
+ return getActiveProfilePubkey();
+ }
+
+ get savedProfiles() {
+ return getSavedProfiles();
+ }
+
constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) {
this.loadView = typeof viewLoader === "function" ? viewLoader : null;
@@ -251,6 +260,16 @@ class Application {
const { modalManager } = this.bootstrapper.initialize();
this.modalManager = modalManager;
+ this.videoModalController = new VideoModalController({
+ getVideoModal: () => this.videoModal,
+ callbacks: {
+ showError: (msg) => this.showError(msg),
+ getLastModalTrigger: () => this.lastModalTrigger,
+ setLastModalTrigger: (val) => this.setLastModalTrigger(val),
+ getCurrentVideo: () => this.currentVideo,
+ },
+ });
+
this.modalCreatorProfileRequestToken = null;
this.dmRecipientPubkey = null;
this.dmRelayHints = new Map();
@@ -1176,162 +1195,21 @@ class Application {
* Show the modal and set the "Please stand by" poster on the video.
*/
async showModalWithPoster(video = this.currentVideo, options = {}) {
- if (!this.videoModal) {
- return null;
- }
-
- if (Object.prototype.hasOwnProperty.call(options || {}, "trigger")) {
- this.setLastModalTrigger(options.trigger);
- }
-
- const targetVideo = video || this.currentVideo;
- if (!targetVideo) {
- this.log(
- "[Application] Skipping video modal open; no target video is available.",
- );
- return null;
- }
-
- try {
- const { root } = await this.ensureVideoModalReady({
- ensureVideoElement: true,
- });
-
- if (!this.videoModal) {
- return root || null;
- }
-
- this.videoModal.open(targetVideo, {
- triggerElement: this.lastModalTrigger,
- });
- this.applyModalLoadingPoster();
-
- return (
- root ||
- (typeof this.videoModal.getRoot === "function"
- ? this.videoModal.getRoot()
- : null)
- );
- } catch (error) {
- devLogger.error(
- "[Application] Failed to open the video modal before playback:",
- error
- );
- this.showError("Could not open the video player. Please try again.");
- return null;
- }
+ return this.videoModalController.showModalWithPoster(video, options);
}
applyModalLoadingPoster() {
- if (!this.videoModal) {
- return;
- }
- this.videoModal.applyLoadingPoster();
+ this.videoModalController.applyModalLoadingPoster();
}
forceRemoveModalPoster(reason = "manual-clear") {
- if (!this.videoModal) {
- return false;
- }
- return this.videoModal.forceRemovePoster(reason);
+ return this.videoModalController.forceRemoveModalPoster(reason);
}
async ensureVideoModalReady({ ensureVideoElement = false } = {}) {
- if (!this.videoModal) {
- throw new Error("Video modal instance is not available.");
- }
-
- const getRoot = () =>
- typeof this.videoModal.getRoot === "function"
- ? this.videoModal.getRoot()
- : null;
- const getVideoElement = () =>
- typeof this.videoModal.getVideoElement === "function"
- ? this.videoModal.getVideoElement()
- : null;
-
- const existingRoot = getRoot();
- const existingVideoElement = getVideoElement();
- const rootConnected = Boolean(existingRoot && existingRoot.isConnected);
- const hasVideoElement = Boolean(existingVideoElement);
- const videoConnected = Boolean(
- existingVideoElement && existingVideoElement.isConnected,
- );
-
- const needsRehydrate =
- !rootConnected || !hasVideoElement || !videoConnected;
-
- if (!needsRehydrate) {
- this.modalVideo = existingVideoElement;
-
- if (
- existingVideoElement &&
- this.videoModal &&
- typeof this.videoModal.setVideoElement === "function"
- ) {
- const modalVideo =
- typeof this.videoModal.getVideoElement === "function"
- ? this.videoModal.getVideoElement()
- : null;
- if (modalVideo !== existingVideoElement) {
- this.videoModal.setVideoElement(existingVideoElement);
- }
- }
-
- return {
- root: existingRoot,
- videoElement: existingVideoElement,
- };
- }
-
- if (!videoConnected) {
- this.modalVideo = null;
- }
-
- if (!this.videoModalReadyPromise) {
- if (typeof this.videoModal.load !== "function") {
- throw new Error("Video modal does not expose a load() method.");
- }
- this.videoModalReadyPromise = Promise.resolve(this.videoModal.load());
- }
-
- try {
- await this.videoModalReadyPromise;
- } catch (error) {
- this.videoModalReadyPromise = null;
- throw error;
- }
-
- this.videoModalReadyPromise = null;
-
- const readyRoot = getRoot();
- const readyVideoElement = getVideoElement();
- const readyVideoConnected = Boolean(
- readyVideoElement && readyVideoElement.isConnected,
- );
-
- if (readyVideoConnected) {
- this.modalVideo = readyVideoElement;
- if (
- this.videoModal &&
- typeof this.videoModal.setVideoElement === "function"
- ) {
- this.videoModal.setVideoElement(readyVideoElement);
- }
- } else {
- this.modalVideo = null;
- }
-
- if (ensureVideoElement && !readyVideoConnected) {
- throw new Error(
- "Video modal video element is missing after load().",
- );
- }
-
- return {
- root: readyRoot,
- videoElement: readyVideoConnected ? readyVideoElement : null,
- };
+ const result = await this.videoModalController.ensureVideoModalReady({ ensureVideoElement });
+ this.modalVideo = result.videoElement;
+ return result;
}
initializeSimilarContentController() {
@@ -10024,7 +9902,6 @@ class Application {
destroy() {
this.clearActiveIntervals();
this.teardownModalViewCountSubscription();
- this.videoModalReadyPromise = null;
if (this.moderationActionController) {
try {
diff --git a/js/config.js b/js/config.js
index 73d5fe76..d8c31343 100644
--- a/js/config.js
+++ b/js/config.js
@@ -47,10 +47,39 @@ import {
export const WATCH_HISTORY_VERSION_TAG_VALUE = "2";
-export const isDevMode = Boolean(IS_DEV_MODE);
+const runtimeScope = typeof globalThis === "undefined" ? null : globalThis;
+
+const parseBooleanOverride = (value) => {
+ if (typeof value === "boolean") {
+ return value;
+ }
+ if (typeof value === "string") {
+ const normalized = value.trim().toLowerCase();
+ if (normalized === "true") {
+ return true;
+ }
+ if (normalized === "false") {
+ return false;
+ }
+ }
+ return null;
+};
+
+const devModeOverride = parseBooleanOverride(
+ runtimeScope?.__BITVID_DEV_MODE_OVERRIDE__
+);
+const verboseDevModeOverride = parseBooleanOverride(
+ runtimeScope?.__BITVID_VERBOSE_DEV_MODE_OVERRIDE__
+);
+
+export const isDevMode =
+ devModeOverride === null ? Boolean(IS_DEV_MODE) : devModeOverride;
export { IS_DEV_MODE };
-export const isVerboseDevMode = Boolean(IS_VERBOSE_DEV_MODE);
+export const isVerboseDevMode =
+ verboseDevModeOverride === null
+ ? Boolean(IS_VERBOSE_DEV_MODE)
+ : verboseDevModeOverride;
export { IS_VERBOSE_DEV_MODE };
export const isLockdownMode = Boolean(IS_LOCKDOWN_MODE);
diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js
index 514242ed..25e5cf6a 100644
--- a/js/nostr/nip71.js
+++ b/js/nostr/nip71.js
@@ -6,6 +6,10 @@ import {
resolveInfoJsonUrl,
} from "../utils/storagePointer.js";
import { getCachedNostrTools } from "./toolkit.js";
+import {
+ validateEventAgainstSchema,
+ NOTE_TYPES,
+} from "../nostrEventSchemas.js";
function stringFromInput(value) {
if (value === null || value === undefined) {
@@ -934,13 +938,20 @@ export function buildNip71VideoEvent({
tags.push(...pointerTags);
}
- return {
+ const event = {
kind: normalizeNip71Kind(metadata.kind),
pubkey: normalizedPubkey,
created_at: timestamp,
tags,
content: summary,
};
+
+ validateEventAgainstSchema(
+ event.kind === 22 ? NOTE_TYPES.NIP71_SHORT_VIDEO : NOTE_TYPES.NIP71_VIDEO,
+ event,
+ );
+
+ return event;
}
export function collectNip71PointerRequests(videos = []) {
diff --git a/js/ui/videoModalController.js b/js/ui/videoModalController.js
new file mode 100644
index 00000000..7f32497a
--- /dev/null
+++ b/js/ui/videoModalController.js
@@ -0,0 +1,202 @@
+import { devLogger } from "../utils/logger.js";
+
+export default class VideoModalController {
+ constructor({
+ getVideoModal,
+ callbacks: {
+ showError,
+ getLastModalTrigger,
+ setLastModalTrigger,
+ getCurrentVideo,
+ } = {},
+ }) {
+ this.getVideoModal = getVideoModal;
+ this.showError = showError;
+ this.getLastModalTrigger = getLastModalTrigger;
+ this.setLastModalTrigger = setLastModalTrigger;
+ this.getCurrentVideo = getCurrentVideo;
+
+ this.videoModalReadyPromise = null;
+ }
+
+ get videoModal() {
+ return this.getVideoModal ? this.getVideoModal() : null;
+ }
+
+ log(message, ...args) {
+ devLogger.log("[VideoModalController]", message, ...args);
+ }
+
+ /**
+ * Show the modal and set the "Please stand by" poster on the video.
+ */
+ async showModalWithPoster(video = null, options = {}) {
+ if (!this.videoModal) {
+ return null;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(options || {}, "trigger")) {
+ if (this.setLastModalTrigger) {
+ this.setLastModalTrigger(options.trigger);
+ }
+ }
+
+ const targetVideo =
+ video || (this.getCurrentVideo ? this.getCurrentVideo() : null);
+ if (!targetVideo) {
+ this.log("Skipping video modal open; no target video is available.");
+ return null;
+ }
+
+ try {
+ const { root } = await this.ensureVideoModalReady({
+ ensureVideoElement: true,
+ });
+
+ if (!this.videoModal) {
+ return root || null;
+ }
+
+ const trigger = this.getLastModalTrigger
+ ? this.getLastModalTrigger()
+ : null;
+ this.videoModal.open(targetVideo, {
+ triggerElement: trigger,
+ });
+ this.applyModalLoadingPoster();
+
+ return (
+ root ||
+ (typeof this.videoModal.getRoot === "function"
+ ? this.videoModal.getRoot()
+ : null)
+ );
+ } catch (error) {
+ devLogger.error(
+ "[VideoModalController] Failed to open the video modal before playback:",
+ error,
+ );
+ if (this.showError) {
+ this.showError("Could not open the video player. Please try again.");
+ }
+ return null;
+ }
+ }
+
+ applyModalLoadingPoster() {
+ if (!this.videoModal) {
+ return;
+ }
+ this.videoModal.applyLoadingPoster();
+ }
+
+ forceRemoveModalPoster(reason = "manual-clear") {
+ if (!this.videoModal) {
+ return false;
+ }
+ return this.videoModal.forceRemovePoster(reason);
+ }
+
+ async ensureVideoModalReady({ ensureVideoElement = false } = {}) {
+ if (!this.videoModal) {
+ throw new Error("Video modal instance is not available.");
+ }
+
+ const getRoot = () =>
+ typeof this.videoModal.getRoot === "function"
+ ? this.videoModal.getRoot()
+ : null;
+ const getVideoElement = () =>
+ typeof this.videoModal.getVideoElement === "function"
+ ? this.videoModal.getVideoElement()
+ : null;
+
+ const existingRoot = getRoot();
+ const existingVideoElement = getVideoElement();
+ const rootConnected = Boolean(existingRoot && existingRoot.isConnected);
+ const hasVideoElement = Boolean(existingVideoElement);
+ const videoConnected = Boolean(
+ existingVideoElement && existingVideoElement.isConnected,
+ );
+
+ const needsRehydrate =
+ !rootConnected || !hasVideoElement || !videoConnected;
+
+ if (!needsRehydrate) {
+ if (
+ existingVideoElement &&
+ this.videoModal &&
+ typeof this.videoModal.setVideoElement === "function"
+ ) {
+ const currentModalVideo =
+ typeof this.videoModal.getVideoElement === "function"
+ ? this.videoModal.getVideoElement()
+ : null;
+ if (currentModalVideo !== existingVideoElement) {
+ this.videoModal.setVideoElement(existingVideoElement);
+ }
+ }
+
+ return {
+ root: existingRoot,
+ videoElement: existingVideoElement,
+ };
+ }
+
+ if (!videoConnected) {
+ if (
+ this.videoModal &&
+ typeof this.videoModal.setVideoElement === "function"
+ ) {
+ this.videoModal.setVideoElement(null);
+ }
+ }
+
+ if (!this.videoModalReadyPromise) {
+ if (typeof this.videoModal.load !== "function") {
+ throw new Error("Video modal does not expose a load() method.");
+ }
+ this.videoModalReadyPromise = Promise.resolve(this.videoModal.load());
+ }
+
+ try {
+ await this.videoModalReadyPromise;
+ } catch (error) {
+ this.videoModalReadyPromise = null;
+ throw error;
+ }
+
+ this.videoModalReadyPromise = null;
+
+ const readyRoot = getRoot();
+ const readyVideoElement = getVideoElement();
+ const readyVideoConnected = Boolean(
+ readyVideoElement && readyVideoElement.isConnected,
+ );
+
+ if (readyVideoConnected) {
+ if (
+ this.videoModal &&
+ typeof this.videoModal.setVideoElement === "function"
+ ) {
+ this.videoModal.setVideoElement(readyVideoElement);
+ }
+ } else {
+ if (
+ this.videoModal &&
+ typeof this.videoModal.setVideoElement === "function"
+ ) {
+ this.videoModal.setVideoElement(null);
+ }
+ }
+
+ if (ensureVideoElement && !readyVideoConnected) {
+ throw new Error("Video modal video element is missing after load().");
+ }
+
+ return {
+ root: readyRoot,
+ videoElement: readyVideoConnected ? readyVideoElement : null,
+ };
+ }
+}
diff --git a/netlify.toml b/netlify.toml
index a1680b2f..fc176b6f 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -1,3 +1,14 @@
[build]
command = "npm run build"
publish = "dist"
+
+[[redirects]]
+ from = "/*"
+ to = "/index.html"
+ status = 200
+ force = true
+
+[[headers]]
+ for = "/*"
+ [headers.values]
+ Content-Security-Policy = "frame-ancestors *"
diff --git a/package-lock.json b/package-lock.json
index 5a96352f..e2ae9c07 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,14 +11,16 @@
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@noble/hashes": "^2.0.1",
+ "@tailwindcss/typography": "^0.5.19",
+ "autoprefixer": "^10.4.23",
"jsdom": "^27.4.0",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.19",
"ws": "^8.19.0"
},
"devDependencies": {
"@noble/hashes": "^2.0.1",
"@playwright/test": "^1.58.0",
- "@tailwindcss/typography": "^0.5.15",
- "autoprefixer": "^10.4.23",
"cross-env": "^7.0.3",
"esbuild": "^0.25.12",
"fake-indexeddb": "^6.2.5",
@@ -26,12 +28,10 @@
"pixelmatch": "^5.3.0",
"playwright": "^1.58.0",
"pngjs": "^7.0.0",
- "postcss": "^8.4.41",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.5",
"serve": "^14.2.5",
- "stylelint": "^16.26.1",
- "tailwindcss": "^3.4.19"
+ "stylelint": "^16.26.1"
}
},
"node_modules/@acemir/cssom": {
@@ -44,7 +44,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -783,7 +782,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -794,7 +792,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -804,14 +801,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -895,7 +890,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -909,7 +903,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -919,7 +912,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -1043,7 +1035,6 @@
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
@@ -1173,14 +1164,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
- "dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
- "dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -1215,7 +1204,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
- "dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -1249,7 +1237,6 @@
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -1293,7 +1280,6 @@
"version": "2.9.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
"integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
- "dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -1312,7 +1298,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1372,7 +1357,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -1385,7 +1369,6 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -1466,7 +1449,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -1476,7 +1458,6 @@
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -1530,7 +1511,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -1613,7 +1593,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -1794,7 +1773,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
- "dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -1877,7 +1855,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
- "dev": true,
"license": "Apache-2.0"
},
"node_modules/dir-glob": {
@@ -1897,7 +1874,6 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
- "dev": true,
"license": "MIT"
},
"node_modules/eastasianwidth": {
@@ -1911,7 +1887,6 @@
"version": "1.5.279",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz",
"integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==",
- "dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -1999,7 +1974,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2057,7 +2031,6 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -2084,7 +2057,6 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
- "dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -2104,7 +2076,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -2136,7 +2107,6 @@
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": "*"
@@ -2150,7 +2120,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -2165,7 +2134,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -2188,7 +2156,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -2303,7 +2270,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -2433,7 +2399,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -2446,7 +2411,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -2478,7 +2442,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -2498,7 +2461,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -2511,7 +2473,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -2583,7 +2544,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
- "dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -2705,7 +2665,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -2718,7 +2677,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
- "dev": true,
"license": "MIT"
},
"node_modules/lodash.truncate": {
@@ -2778,7 +2736,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -2788,7 +2745,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -2874,7 +2830,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -2886,7 +2841,6 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -2915,14 +2869,12 @@
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -2989,7 +2941,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -2999,7 +2950,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3096,7 +3046,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true,
"license": "MIT"
},
"node_modules/path-to-regexp": {
@@ -3120,14 +3069,12 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -3140,7 +3087,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3150,7 +3096,6 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -3225,7 +3170,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -3254,7 +3198,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
- "dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -3272,7 +3215,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -3298,7 +3240,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -3341,7 +3282,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -3367,7 +3307,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -3415,7 +3354,6 @@
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
- "dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -3429,7 +3367,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/prettier": {
@@ -3561,7 +3498,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -3608,7 +3544,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -3618,7 +3553,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -3664,7 +3598,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@@ -3695,7 +3628,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -3706,7 +3638,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -4094,7 +4025,6 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -4147,7 +4077,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4234,7 +4163,6 @@
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -4272,7 +4200,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
- "dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -4285,7 +4212,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -4299,7 +4225,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -4309,7 +4234,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -4322,7 +4246,6 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -4339,7 +4262,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -4357,7 +4279,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -4388,7 +4309,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -4425,7 +4345,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
- "dev": true,
"license": "Apache-2.0"
},
"node_modules/type-fest": {
@@ -4445,7 +4364,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -4497,7 +4415,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
"license": "MIT"
},
"node_modules/vary": {
diff --git a/package.json b/package.json
index 4df9e94a..af5bd559 100644
--- a/package.json
+++ b/package.json
@@ -35,8 +35,6 @@
"devDependencies": {
"@noble/hashes": "^2.0.1",
"@playwright/test": "^1.58.0",
- "@tailwindcss/typography": "^0.5.15",
- "autoprefixer": "^10.4.23",
"cross-env": "^7.0.3",
"esbuild": "^0.25.12",
"fake-indexeddb": "^6.2.5",
@@ -44,18 +42,20 @@
"pixelmatch": "^5.3.0",
"playwright": "^1.58.0",
"pngjs": "^7.0.0",
- "postcss": "^8.4.41",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.5",
"serve": "^14.2.5",
- "stylelint": "^16.26.1",
- "tailwindcss": "^3.4.19"
+ "stylelint": "^16.26.1"
},
"license": "GPL-3.0-or-later",
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@noble/hashes": "^2.0.1",
+ "@tailwindcss/typography": "^0.5.19",
+ "autoprefixer": "^10.4.23",
"jsdom": "^27.4.0",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.19",
"ws": "^8.19.0"
}
}
diff --git a/scripts/agent/smoke-test.mjs b/scripts/agent/smoke-test.mjs
index dcdf5788..3356f454 100644
--- a/scripts/agent/smoke-test.mjs
+++ b/scripts/agent/smoke-test.mjs
@@ -1,286 +1,257 @@
+
import './setup-test-env.js';
-import { indexedDB, IDBKeyRange } from "fake-indexeddb";
-import { WebSocket } from 'ws';
-import * as NostrTools from 'nostr-tools';
-import fs from 'node:fs';
-import path from 'node:path';
-import { spawn } from 'node:child_process';
-import { fileURLToPath } from 'url';
-
-// --- Polyfills ---
-if (!global.indexedDB) {
- global.indexedDB = indexedDB;
-}
-if (!global.IDBKeyRange) {
- global.IDBKeyRange = IDBKeyRange;
-}
-if (!global.NostrTools) {
- global.NostrTools = NostrTools;
-}
-// setup-test-env.js handles WebSocket, crypto, localStorage, window, self, navigator
-
-// --- Imports from Codebase ---
-// Note: We need to use relative paths from scripts/agent/
+import { startRelay } from './load-test-relay.mjs';
import { NostrClient } from '../../js/nostr/client.js';
import { decryptDM } from '../../js/dmDecryptor.js';
-import { startRelay } from './load-test-relay.mjs';
+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';
-// --- Configuration ---
-const ARTIFACTS_DIR = 'artifacts';
-if (!fs.existsSync(ARTIFACTS_DIR)) {
- fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
-}
-
-const RELAY_PORT = 8890;
+const RELAY_PORT = 8899;
const RELAY_URL = `ws://localhost:${RELAY_PORT}`;
const HTTP_PORT = 8000;
const HTTP_URL = `http://localhost:${HTTP_PORT}`;
-// --- Logging ---
-const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD
-const logFile = path.join(ARTIFACTS_DIR, `smoke-${dateStr}.log`);
-const logStream = fs.createWriteStream(logFile, { flags: 'a' });
+// 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');
-function log(msg, ...args) {
- const timestamp = new Date().toISOString();
- const message = `[${timestamp}] ${msg} ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ')}`;
- console.log(message);
- logStream.write(message + '\n');
+// Logger
+function log(message) {
+ const timestamp = new Date().toISOString();
+ const line = `[${timestamp}] ${message}`;
+ console.log(line);
+ fs.appendFileSync(LOG_FILE, line + '\n');
}
-function error(msg, ...args) {
- const timestamp = new Date().toISOString();
- const message = `[${timestamp}] [ERROR] ${msg} ${args.map(a => (a instanceof Error ? a.stack : (typeof a === 'object' ? JSON.stringify(a) : a))).join(' ')}`;
- console.error(message);
- logStream.write(message + '\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.');
}
-// --- Helpers ---
-async function waitForServer(url, timeoutMs = 10000) {
- const start = Date.now();
- while (Date.now() - start < timeoutMs) {
+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 {
- const res = await fetch(url);
- if (res.ok) return true;
- } catch (e) {
- // ignore
+ 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::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 ---');
+
+ } 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);
}
- await new Promise(r => setTimeout(r, 200));
- }
- return false;
}
-// --- Main Test Flow ---
-async function run() {
- let relayServer = null;
- let httpServer = null;
- let client = null;
- let success = true;
-
- try {
- log('--- Starting Smoke Test ---');
-
- // 1. Start Relay
- log(`Starting local relay on port ${RELAY_PORT}...`);
- relayServer = startRelay(RELAY_PORT);
- // Wait a bit for relay to be ready (startRelay is synchronous in setup but async in effect?)
- // It uses WebSocketServer which starts immediately.
- await new Promise(r => setTimeout(r, 500));
- log('Relay started.');
-
- // 2. Start HTTP Server
- log(`Starting HTTP server (npx serve) on port ${HTTP_PORT}...`);
- httpServer = spawn('npx', ['serve', '-p', String(HTTP_PORT)], {
- stdio: 'ignore', // or 'inherit' to see output
- detached: false
- });
-
- const serverReady = await waitForServer(HTTP_URL);
- if (!serverReady) {
- throw new Error(`HTTP server did not start on ${HTTP_URL} within timeout.`);
- }
- log('HTTP server is ready.');
-
- // 3. Init NostrClient
- log('Initializing NostrClient...');
- client = new NostrClient();
- client.relays = [RELAY_URL];
- client.readRelays = [RELAY_URL];
- client.writeRelays = [RELAY_URL];
-
- // Override dev logger to avoid pollution if needed, or let it log
- // We can rely on standard logging.
-
- await client.init();
- log(`NostrClient initialized and connected to ${RELAY_URL}`);
-
- // 4. Login (Ephemeral)
- log('Generating ephemeral keys for Sender...');
- const senderSk = NostrTools.generateSecretKey();
- const senderSkHex = Buffer.from(senderSk).toString('hex');
- const senderPk = NostrTools.getPublicKey(senderSk);
-
- log(`Sender Pubkey: ${senderPk}`);
-
- await client.registerPrivateKeySigner({ privateKey: senderSkHex, persist: false });
- client.pubkey = senderPk; // Manually set logged-in user
-
- if (client.pubkey !== senderPk) {
- throw new Error(`Client pubkey mismatch. Expected ${senderPk}, got ${client.pubkey}`);
- }
- log('Sender logged in.');
-
- // 5. Publish Video
- log('Publishing test video...');
- const videoPayload = {
- title: `Smoke Test Video ${Date.now()}`,
- description: 'Smoke test description',
- magnet: `magnet:?xt=urn:btih:${Math.random().toString(16).slice(2)}`,
- url: `${HTTP_URL}/test.mp4`, // Fake URL
- thumbnail: `${HTTP_URL}/thumb.jpg`,
- mode: 'live',
- videoRootId: `smoke-${Date.now()}`,
- isNsfw: false,
- isForKids: true
- };
-
- const publishedEvent = await client.publishVideo(videoPayload, senderPk);
- if (!publishedEvent || !publishedEvent.id) {
- throw new Error('Failed to publish video event.');
- }
- log(`Video published. Event ID: ${publishedEvent.id}`);
-
- // Verify Read Back
- log('Verifying video can be fetched...');
- // We use subscribeVideos for main feed, or getEventById.
- // Let's use getEventById for direct verification.
- const fetched = await client.getEventById(publishedEvent.id);
- if (!fetched) {
- throw new Error('Failed to fetch published video.');
- }
- if (fetched.title !== videoPayload.title) {
- throw new Error(`Fetched video title mismatch. Expected "${videoPayload.title}", got "${fetched.title}"`);
- }
- log('Video fetched successfully.');
-
- // 6. DM Flow
- log('Testing DM Flow...');
- const recipientSk = NostrTools.generateSecretKey();
- const recipientSkHex = Buffer.from(recipientSk).toString('hex');
- const recipientPk = NostrTools.getPublicKey(recipientSk);
- const recipientNpub = NostrTools.nip19.npubEncode(recipientPk);
-
- log(`Recipient Pubkey: ${recipientPk}`);
-
- const dmMessage = `Smoke Test Message ${Date.now()}`;
-
- // Send DM
- const sendResult = await client.sendDirectMessage(recipientNpub, dmMessage);
- if (!sendResult.ok) {
- throw new Error(`Failed to send DM: ${sendResult.error}`);
- }
- log('DM sent successfully.');
-
- // Verify Decryption (Simulate Recipient)
- log('Verifying DM decryption (Recipient view)...');
-
- // We need to find the DM event.
- // Since we don't have a second client syncing, we can manually query the pool/relay.
- const filter = {
- kinds: [4, 1059], // 4 for legacy, 1059 for NIP-17
- '#p': [recipientPk],
- limit: 1
- };
-
- // wait a moment for propagation
- await new Promise(r => setTimeout(r, 200));
-
- const events = await client.pool.list([RELAY_URL], [filter]);
- if (events.length === 0) {
- throw new Error('Recipient could not find DM event on relay.');
- }
-
- const dmEvent = events[0];
- log(`Found DM event kind ${dmEvent.kind} id ${dmEvent.id}`);
-
- // Construct decryption context for decryptDM
- // We need a decryptor for the recipient.
- // Since we have the recipient's private key, we can use NostrTools.nip04 or nip44.
-
- const decryptors = [];
-
- // NIP-04 Decryptor
- decryptors.push({
- scheme: 'nip04',
- decrypt: async (remotePubkey, ciphertext) => {
- return await NostrTools.nip04.decrypt(recipientSkHex, remotePubkey, ciphertext);
- }
- });
-
- // NIP-44 Decryptor
- decryptors.push({
- scheme: 'nip44',
- supportsGiftWrap: true,
- decrypt: (remotePubkey, ciphertext) => {
- const conversationKey = NostrTools.nip44.v2.utils.getConversationKey(recipientSk, remotePubkey);
- return NostrTools.nip44.v2.decrypt(ciphertext, conversationKey);
- }
- });
-
- const context = {
- actorPubkey: recipientPk,
- decryptors
- };
-
- const decryptResult = await decryptDM(dmEvent, context);
-
- if (!decryptResult.ok) {
- throw new Error(`DM decryption failed: ${JSON.stringify(decryptResult.errors)}`);
- }
-
- if (decryptResult.plaintext !== dmMessage) {
- throw new Error(`DM plaintext mismatch. Expected "${dmMessage}", got "${decryptResult.plaintext}"`);
- }
-
- log('DM verified successfully.');
-
- } catch (err) {
- success = false;
- error('Smoke Test Failed!', err);
- } finally {
- // Report
- const summary = {
- timestamp: new Date().toISOString(),
- success,
- logFile
- };
- const summaryFile = path.join(ARTIFACTS_DIR, `smoke-summary-${dateStr}.json`);
- fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
- log(`Summary saved to ${summaryFile}`);
-
- // Teardown
- log('Teardown...');
- if (client) {
- // client.logout(); // Cleans up
- // Close pool connections?
- // client.pool.close(client.relays); // SimplePool doesn't have explicit close usually?
- // NostrTools SimplePool usually manages connections.
- // We can just rely on process exit.
- }
-
- if (relayServer) {
- relayServer.close();
- log('Relay stopped.');
- }
-
- if (httpServer) {
- httpServer.kill();
- log('HTTP server stopped.');
- }
-
- log('Done.');
- process.exit(success ? 0 : 1);
- }
-}
-
-run();
+runSmokeTest();
diff --git a/scripts/agent/validate-events.mjs b/scripts/agent/validate-events.mjs
index d3fb2100..97762f14 100644
--- a/scripts/agent/validate-events.mjs
+++ b/scripts/agent/validate-events.mjs
@@ -1,9 +1,48 @@
-#!/usr/bin/env node
+// scripts/agent/validate-events.mjs
+import WebSocket from 'ws';
+import { webcrypto } from 'node:crypto';
+
+// Polyfill WebSocket
+if (typeof globalThis.WebSocket === 'undefined') {
+ globalThis.WebSocket = WebSocket;
+}
+
+// Polyfill window and self
+if (typeof globalThis.window === 'undefined') {
+ globalThis.window = globalThis;
+}
+if (typeof globalThis.self === 'undefined') {
+ globalThis.self = globalThis;
+}
+
+// Polyfill crypto
+if (typeof globalThis.crypto === 'undefined') {
+ globalThis.crypto = webcrypto;
+}
+
+// Polyfill localStorage
+if (typeof globalThis.localStorage === 'undefined') {
+ const storage = new Map();
+ globalThis.localStorage = {
+ getItem: (k) => storage.get(k) || null,
+ setItem: (k, v) => storage.set(String(k), String(v)),
+ removeItem: (k) => storage.delete(k),
+ clear: () => storage.clear(),
+ key: (i) => Array.from(storage.keys())[i] || null,
+ get length() { return storage.size; }
+ };
+}
+
+if (typeof globalThis.window.localStorage === 'undefined') {
+ globalThis.window.localStorage = globalThis.localStorage;
+}
+
+// Mock navigator
+if (typeof globalThis.navigator === 'undefined') {
+ globalThis.navigator = { userAgent: 'node' };
+}
-import { execSync } from "child_process";
import {
- NOTE_TYPES,
- validateEventStructure,
buildVideoPostEvent,
buildHttpAuthEvent,
buildReportEvent,
@@ -28,352 +67,312 @@ import {
buildBlockListEvent,
buildHashtagPreferenceEvent,
buildAdminListEvent,
-} from "../../js/nostrEventSchemas.js";
+ validateEventStructure,
+ NOTE_TYPES,
+ ADMIN_LIST_IDENTIFIERS
+} from '../../js/nostrEventSchemas.js';
+import { buildNip71VideoEvent } from '../../js/nostr/nip71.js';
-const PUBKEY = "0000000000000000000000000000000000000000000000000000000000000001";
-const CREATED_AT = 1700000000;
+// Note: Most builders are defined in js/nostrEventSchemas.js and are already instrumented
+// with validation checks (validateEventAgainstSchema) when running in Dev Mode.
+// buildNip71VideoEvent was instrumented in js/nostr/nip71.js as part of this work.
+// This script verifies that all builders produce valid events according to the schema.
-const IGNORED_BUILDERS = new Set([
- "buildShareUrlFromEventId", // Method in js/app.js, not an event builder
- "buildShareUrlFromEvent", // Substring match or alias
- "buildListEvent", // Local helper in js/adminListStore.js wrapping buildAdminListEvent
- "buildProfileFromEvent", // Local helper in js/services/authService.js
-]);
-
-const KNOWN_BUILDERS = {
- buildVideoPostEvent: {
- builder: buildVideoPostEvent,
+const testCases = [
+ {
+ name: 'Video Post',
type: NOTE_TYPES.VIDEO_POST,
+ builder: buildVideoPostEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- dTagValue: "test-video-id",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ dTagValue: 'test-video',
content: {
version: 3,
- title: "Test Video",
- videoRootId: "test-video-id",
- infoHash: "0123456789abcdef0123456789abcdef01234567",
- },
+ title: 'Test Video',
+ videoRootId: 'test-root',
+ }
}
},
- buildHttpAuthEvent: {
- builder: buildHttpAuthEvent,
+ {
+ name: 'HTTP Auth',
type: NOTE_TYPES.HTTP_AUTH,
+ builder: buildHttpAuthEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- url: "https://example.com/auth",
- method: "GET",
- payload: "hash",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ url: 'https://example.com',
+ method: 'GET'
}
},
- buildReportEvent: {
- builder: buildReportEvent,
+ {
+ name: 'Report',
type: NOTE_TYPES.REPORT,
+ builder: buildReportEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- eventId: "e".repeat(64),
- reportType: "nudity",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ eventId: '0000000000000000000000000000000000000000000000000000000000000002',
+ reportType: 'nudity'
}
},
- buildVideoMirrorEvent: {
- builder: buildVideoMirrorEvent,
+ {
+ name: 'Video Mirror',
type: NOTE_TYPES.VIDEO_MIRROR,
+ builder: buildVideoMirrorEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- tags: [["url", "https://example.com/video.mp4"], ["m", "video/mp4"]],
- content: "Mirroring video",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ content: 'Alt text'
}
},
- buildRepostEvent: {
- builder: buildRepostEvent,
+ {
+ name: 'Repost',
type: NOTE_TYPES.REPOST,
+ builder: buildRepostEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- eventId: "e".repeat(64),
- eventRelay: "wss://relay.example.com",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ eventId: '0000000000000000000000000000000000000000000000000000000000000002',
+ targetKind: 1
}
},
- buildShareEvent: {
- builder: buildShareEvent,
+ {
+ name: 'Share',
type: NOTE_TYPES.SHARE,
+ builder: buildShareEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- video: { id: "e".repeat(64), pubkey: PUBKEY },
- relays: ["wss://relay.example.com"],
- content: "Check this out",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ content: 'Check this out',
+ video: { id: '0000000000000000000000000000000000000000000000000000000000000002' }
}
},
- buildRelayListEvent: {
- builder: buildRelayListEvent,
+ {
+ name: 'Relay List',
type: NOTE_TYPES.RELAY_LIST,
+ builder: buildRelayListEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- relays: [{ url: "wss://relay.example.com", read: true, write: true }],
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ relays: ['wss://relay.example.com']
}
},
- buildDmRelayListEvent: {
- builder: buildDmRelayListEvent,
+ {
+ name: 'DM Relay List',
type: NOTE_TYPES.DM_RELAY_LIST,
+ builder: buildDmRelayListEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- relays: ["wss://relay.example.com"],
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ relays: ['wss://relay.example.com']
}
},
- buildProfileMetadataEvent: {
- builder: buildProfileMetadataEvent,
+ {
+ name: 'Profile Metadata',
type: NOTE_TYPES.PROFILE_METADATA,
+ builder: buildProfileMetadataEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- metadata: { name: "Test User", about: "Testing" },
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ metadata: { name: 'Alice' }
}
},
- buildMuteListEvent: {
- builder: buildMuteListEvent,
+ {
+ name: 'Mute List',
type: NOTE_TYPES.MUTE_LIST,
+ builder: buildMuteListEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- pTags: [PUBKEY],
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ pTags: ['0000000000000000000000000000000000000000000000000000000000000002']
}
},
- buildDeletionEvent: {
- builder: buildDeletionEvent,
+ {
+ name: 'Deletion',
type: NOTE_TYPES.DELETION,
+ builder: buildDeletionEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- eventIds: ["e".repeat(64)],
- reason: "mistake",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ eventIds: ['0000000000000000000000000000000000000000000000000000000000000002']
}
},
- buildLegacyDirectMessageEvent: {
- builder: buildLegacyDirectMessageEvent,
+ {
+ name: 'Legacy DM',
type: NOTE_TYPES.LEGACY_DM,
+ builder: buildLegacyDirectMessageEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- recipientPubkey: PUBKEY,
- ciphertext: "encrypted",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
+ ciphertext: 'base64ciphertext'
}
},
- buildDmAttachmentEvent: {
- builder: buildDmAttachmentEvent,
+ {
+ name: 'DM Attachment',
type: NOTE_TYPES.DM_ATTACHMENT,
+ builder: buildDmAttachmentEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- recipientPubkey: PUBKEY,
- attachment: { url: "https://example.com/file", x: "hash", type: "image/jpeg" },
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
+ attachment: { url: 'https://example.com/file.jpg', x: 'hash' }
}
},
- buildDmReadReceiptEvent: {
- builder: buildDmReadReceiptEvent,
+ {
+ name: 'DM Read Receipt',
type: NOTE_TYPES.DM_READ_RECEIPT,
+ builder: buildDmReadReceiptEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- recipientPubkey: PUBKEY,
- eventId: "e".repeat(64),
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
+ eventId: '0000000000000000000000000000000000000000000000000000000000000002'
}
},
- buildDmTypingIndicatorEvent: {
- builder: buildDmTypingIndicatorEvent,
+ {
+ name: 'DM Typing Indicator',
type: NOTE_TYPES.DM_TYPING,
+ builder: buildDmTypingIndicatorEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- recipientPubkey: PUBKEY,
- expiresAt: CREATED_AT + 60,
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
+ expiresAt: 1234567900
}
},
- buildViewEvent: {
- builder: buildViewEvent,
+ {
+ name: 'View Event',
type: NOTE_TYPES.VIEW_EVENT,
+ builder: buildViewEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- pointerValue: "e".repeat(64),
- pointerTag: ["e", "e".repeat(64)],
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ pointerValue: '0000000000000000000000000000000000000000000000000000000000000002'
}
},
- buildZapRequestEvent: {
- builder: buildZapRequestEvent,
+ {
+ name: 'Zap Request',
type: NOTE_TYPES.ZAP_REQUEST,
+ builder: buildZapRequestEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- recipientPubkey: PUBKEY,
- amountSats: 100,
- relays: ["wss://relay.example.com"],
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ recipientPubkey: '0000000000000000000000000000000000000000000000000000000000000002',
+ amountSats: 100
}
},
- buildReactionEvent: {
- builder: buildReactionEvent,
+ {
+ name: 'Reaction',
type: NOTE_TYPES.VIDEO_REACTION,
+ builder: buildReactionEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- pointerValue: "e".repeat(64),
- targetPointer: { type: "e", value: "e".repeat(64) },
- content: "+",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ pointerValue: '0000000000000000000000000000000000000000000000000000000000000002',
+ content: '+'
}
},
- buildCommentEvent: {
- builder: buildCommentEvent,
+ {
+ name: 'Comment',
type: NOTE_TYPES.VIDEO_COMMENT,
+ builder: buildCommentEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- videoEventId: "e".repeat(64),
- content: "Nice video",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ videoEventId: '0000000000000000000000000000000000000000000000000000000000000002',
+ content: 'Nice video'
}
},
- buildWatchHistoryEvent: {
- builder: buildWatchHistoryEvent,
+ {
+ name: 'Watch History',
type: NOTE_TYPES.WATCH_HISTORY,
+ builder: buildWatchHistoryEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- monthIdentifier: "2023-01",
- content: { version: 2, month: "2023-01", items: [] },
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ monthIdentifier: 'watch-history:2025-01',
+ content: { version: 2, month: '2025-01', items: [] }
}
},
- buildSubscriptionListEvent: {
- builder: buildSubscriptionListEvent,
+ {
+ name: 'Subscription List',
type: NOTE_TYPES.SUBSCRIPTION_LIST,
+ builder: buildSubscriptionListEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- content: "encrypted-json",
- encryption: "nip44_v2",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ content: 'encrypted-payload'
}
},
- buildBlockListEvent: {
- builder: buildBlockListEvent,
+ {
+ name: 'Block List',
type: NOTE_TYPES.USER_BLOCK_LIST,
+ builder: buildBlockListEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- content: "encrypted-json",
- encryption: "nip44_v2",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ content: 'encrypted-payload'
}
},
- buildHashtagPreferenceEvent: {
- builder: buildHashtagPreferenceEvent,
+ {
+ name: 'Hashtag Preferences',
type: NOTE_TYPES.HASHTAG_PREFERENCES,
+ builder: buildHashtagPreferenceEvent,
params: {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- content: "encrypted-json",
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ content: JSON.stringify({ version: 1, interests: [], disinterests: [] })
}
},
- buildAdminListEvent: {
- builder: buildAdminListEvent,
+ {
+ name: 'Admin List (Moderation)',
type: NOTE_TYPES.ADMIN_MODERATION_LIST,
- // Special handling for args
- args: ["moderation", {
- pubkey: PUBKEY,
- created_at: CREATED_AT,
- hexPubkeys: [PUBKEY],
- }]
+ builder: (params) => buildAdminListEvent(ADMIN_LIST_IDENTIFIERS.moderation, params),
+ params: {
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ created_at: 1234567890,
+ hexPubkeys: []
+ }
},
-};
-
-function getUsedBuilders() {
- console.log("Scanning repo for event builder usage...");
- try {
- const output = execSync('grep -r "build[A-Za-z]\\+Event" js/', { encoding: 'utf-8' });
- const lines = output.split('\n');
- const usedBuilders = new Set();
- const regex = /(build[A-Za-z]+Event)/g;
-
- lines.forEach(line => {
- // Skip the schemas file itself
- if (line.includes('js/nostrEventSchemas.js')) return;
-
- const matches = line.match(regex);
- if (matches) {
- matches.forEach(match => usedBuilders.add(match));
+ {
+ name: 'NIP-71 Video',
+ type: NOTE_TYPES.NIP71_VIDEO,
+ builder: buildNip71VideoEvent,
+ params: {
+ pubkey: '0000000000000000000000000000000000000000000000000000000000000001',
+ title: 'Test NIP-71',
+ metadata: { kind: 21, title: 'Test NIP-71' },
+ pointerIdentifiers: { videoRootId: 'test-root' }
}
- });
- return usedBuilders;
- } catch (error) {
- // Grep returns status 1 if no matches found, which might throw an error in execSync
- // depending on the version/platform, or just empty output.
- console.error("Grep failed or found nothing:", error.message);
- return new Set();
}
-}
+];
-async function run() {
- const usedBuilders = getUsedBuilders();
- console.log(`Found ${usedBuilders.size} unique builder(s) used in the codebase.`);
-
- // also check if we missed any exported builder in our KNOWN_BUILDERS list
- // by assuming KNOWN_BUILDERS contains all we want to support.
-
- let failureCount = 0;
- const missingTests = [];
-
- usedBuilders.forEach(builderName => {
- if (!KNOWN_BUILDERS[builderName] && !IGNORED_BUILDERS.has(builderName)) {
- missingTests.push(builderName);
- }
- });
-
- if (missingTests.length > 0) {
- console.warn("\n[WARNING] The following builders are used in the repo but have no test case in this script:");
- missingTests.forEach(name => console.warn(` - ${name}`));
- // We don't fail the build for this yet, but we warn.
- // Or maybe we should fail? The prompt said "Check runtime event construction...".
- // Let's count it as a "gap" but allow passing if validation of known ones works.
- }
-
- console.log("\nValidating builders...");
-
- for (const [name, config] of Object.entries(KNOWN_BUILDERS)) {
- // If we want to be strict, we could check if it is used. But validation is good regardless.
- const isUsed = usedBuilders.has(name);
- const usageLabel = isUsed ? "(USED)" : "(UNUSED)";
+let failed = false;
+for (const testCase of testCases) {
try {
- let event;
- if (config.args) {
- event = config.builder(...config.args);
- } else {
- event = config.builder(config.params);
- }
-
- const { valid, errors } = validateEventStructure(config.type, event);
-
- if (valid) {
- console.log(`[PASS] ${name} ${usageLabel}`);
- } else {
- console.error(`[FAIL] ${name} ${usageLabel}`);
- errors.forEach((err) => console.error(` - ${err}`));
- failureCount++;
- }
- } catch (e) {
- console.error(`[ERROR] ${name} ${usageLabel} threw an exception:`, e);
- failureCount++;
+ const event = testCase.builder(testCase.params);
+ if (!event) {
+ console.error(`[FAIL] ${testCase.name}: Builder returned null`);
+ failed = true;
+ continue;
+ }
+ const { valid, errors } = validateEventStructure(testCase.type, event);
+ if (!valid) {
+ console.error(`[FAIL] ${testCase.name}:`);
+ errors.forEach(err => console.error(` - ${err}`));
+ failed = true;
+ } else {
+ console.log(`[PASS] ${testCase.name}`);
+ }
+ } catch (err) {
+ console.error(`[FAIL] ${testCase.name} threw error:`, err);
+ failed = true;
}
- }
-
- if (failureCount > 0) {
- console.error(`\nValidation failed with ${failureCount} errors.`);
- process.exit(1);
- } else {
- console.log("\nAll known builders validated successfully.");
- process.exit(0);
- }
}
-run();
+if (failed) {
+ process.exit(1);
+}
diff --git a/scripts/automated-pr-review.mjs b/scripts/automated-pr-review.mjs
index c672d4ab..a767c60d 100644
--- a/scripts/automated-pr-review.mjs
+++ b/scripts/automated-pr-review.mjs
@@ -26,9 +26,8 @@ function runCommand(command, args = [], options = {}) {
return result;
}
-function getChangedFiles() {
+function getChangedFiles(baseRef) {
try {
- const baseRef = process.env.GITHUB_BASE_REF || 'main';
const baseUrl = process.env.GITHUB_BASE_REPO_URL;
let remote = 'origin';
@@ -64,7 +63,6 @@ function getChangedFiles() {
console.error('Error getting changed files:', error.stderr || error.message);
// Fallback: try 2-dot diff if 3-dot failed (e.g. shallow clone issues)
try {
- const baseRef = process.env.GITHUB_BASE_REF || 'main';
const remote = process.env.GITHUB_BASE_REPO_URL ? 'upstream' : 'origin';
const cmd = `git diff --name-only ${remote}/${baseRef} HEAD`;
console.log(`Retrying with 2-dot diff: ${cmd}`);
@@ -77,8 +75,7 @@ function getChangedFiles() {
}
}
-function checkReleaseChannel(changedFiles) {
- const baseRef = process.env.GITHUB_BASE_REF || 'main';
+function checkReleaseChannel(changedFiles, baseRef) {
const warnings = [];
if (baseRef === 'main') {
@@ -100,13 +97,11 @@ function checkReleaseChannel(changedFiles) {
return warnings;
}
-async function main() {
- try {
- console.log('Starting Automated PR Review...');
- let commentBody = '## 🤖 Automated PR Review\n\n';
- let hasFailures = false;
+async function reviewPR(prNumber, baseRef) {
+ console.log(`Starting Automated PR Review for PR #${prNumber} (base: ${baseRef})...`);
+ let commentBody = '## 🤖 Automated PR Review\n\n';
+ let hasFailures = false;
let hasFormatChanges = false;
- let microFixesStatus = '';
// 1. Install Dependencies
if (process.env.CI || process.argv.includes('--force-ci')) {
@@ -149,13 +144,13 @@ async function main() {
}
// 5. Security/Protocol Review Flags
- const changedFiles = getChangedFiles();
+ const changedFiles = getChangedFiles(baseRef);
const sensitiveChanges = changedFiles.filter(file =>
SENSITIVE_PATHS.some(pattern => pattern.test(file))
);
// Release Channel Check
- const channelWarnings = checkReleaseChannel(changedFiles);
+ const channelWarnings = checkReleaseChannel(changedFiles, baseRef);
if (channelWarnings.length > 0) {
commentBody += '### 🚀 Release Channel Checks\n\n';
channelWarnings.forEach(w => commentBody += `${w}\n\n`);
@@ -182,7 +177,7 @@ async function main() {
commentBody += '\n';
// 6. Post Comment (if in CI)
- if (process.env.GITHUB_TOKEN && process.env.PR_NUMBER) {
+ if (process.env.GITHUB_TOKEN && prNumber) {
// Verify gh CLI is available
console.log('Verifying GitHub CLI...');
runCommand('gh', ['--version']);
@@ -194,7 +189,7 @@ async function main() {
execSync('git config user.name "bitvid-agent"');
execSync('git config user.email "agent@bitvid.network"');
execSync('git add .');
- execSync('git commit -m "fix(ai): formatting (agent)"');
+ execSync('git commit -m "fix(ai): formatting (agent) suggested"');
execSync('git push');
commentBody += '> **✅ Micro-fixes applied:** Formatting fixes have been committed to this branch.\n\n';
@@ -204,13 +199,11 @@ async function main() {
// Fallback: Create a new branch and open a PR
try {
console.log('Attempting to create a follow-up PR with fixes...');
- const prNumber = process.env.PR_NUMBER;
const fixBranchName = `agent-fix/pr-${prNumber}-${Date.now()}`;
// Push the current HEAD (with fixes) to a new branch on origin
execSync(`git push origin HEAD:refs/heads/${fixBranchName}`);
- const baseRef = process.env.GITHUB_BASE_REF || 'main';
const prTitle = `fix(ai): formatting fixes for PR #${prNumber}`;
const prBody = `This PR applies automated formatting fixes for PR #${prNumber}.`;
@@ -231,9 +224,9 @@ async function main() {
}
}
- console.log('Posting comment to PR #' + process.env.PR_NUMBER);
+ console.log('Posting comment to PR #' + prNumber);
try {
- const gh = spawnSync('gh', ['pr', 'comment', process.env.PR_NUMBER, '-F', '-'], {
+ const gh = spawnSync('gh', ['pr', 'comment', prNumber, '-F', '-'], {
input: commentBody,
stdio: ['pipe', 'inherit', 'inherit']
});
@@ -247,17 +240,92 @@ async function main() {
}
- // Always print the report to stdout so it's visible in logs (especially if posting fails or for debugging)
+ // Always print the report to stdout
console.log('---------------------------------------------------');
- console.log('PR Review Report:');
+ console.log(`PR Review Report for PR #${prNumber}:`);
console.log(commentBody);
console.log('---------------------------------------------------');
- if (hasFailures) {
- process.exit(1);
+ if (hasFailures) {
+ throw new Error('Lint or Test failures detected.');
+ }
+}
+
+async function main() {
+ try {
+ const isAll = process.argv.includes('--all');
+
+ if (isAll) {
+ console.log('Fetching all open PRs...');
+ // Get list of open PRs
+ const listCmd = ['pr', 'list', '--state', 'open', '--json', 'number,baseRefName,headRefName'];
+ const result = spawnSync('gh', listCmd, { encoding: 'utf-8' });
+
+ if (result.error || result.status !== 0) {
+ throw new Error(`Failed to list PRs: ${result.stderr || result.error?.message}`);
+ }
+
+ const prs = JSON.parse(result.stdout);
+ console.log(`Found ${prs.length} open PRs.`);
+
+ let hasGlobalFailures = false;
+
+ // Save current branch to restore later
+ let originalBranch;
+ try {
+ originalBranch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
+ } catch (e) {
+ // Ignore
+ }
+
+ for (const pr of prs) {
+ try {
+ console.log(`\n=== Processing PR #${pr.number} ===`);
+
+ // Checkout PR
+ runCommand('gh', ['pr', 'checkout', pr.number]);
+
+ // Run Review
+ await reviewPR(pr.number, pr.baseRefName);
+
+ } catch (e) {
+ console.error(`❌ Failed to process PR #${pr.number}:`, e.message);
+ hasGlobalFailures = true;
+ // Continue to next PR
+ } finally {
+ // Clean up or reset if needed?
+ // Since we use gh pr checkout, we are on a different branch.
+ // Next iteration will checkout another branch.
+ }
+ }
+
+ if (originalBranch) {
+ console.log(`Restoring original branch: ${originalBranch}`);
+ try {
+ execSync(`git checkout ${originalBranch}`);
+ } catch(e) {
+ console.error('Failed to restore original branch');
+ }
+ }
+
+ if (hasGlobalFailures) {
+ process.exit(1);
+ }
+
+ } else {
+ const prNumber = process.env.PR_NUMBER;
+ const baseRef = process.env.GITHUB_BASE_REF || 'main';
+
+ if (prNumber) {
+ await reviewPR(prNumber, baseRef);
+ } else {
+ // Local run or manual invocation without specific PR env vars
+ console.log('No PR_NUMBER found and no --all flag. Running local review against ' + baseRef);
+ await reviewPR('local', baseRef);
+ }
}
} catch (error) {
- console.error('Automated PR Review failed:', error);
+ console.error('Automated PR Review failed:', error.message);
process.exit(1);
}
}
diff --git a/scripts/build-dist.mjs b/scripts/build-dist.mjs
index 0153dd4a..b30f1167 100644
--- a/scripts/build-dist.mjs
+++ b/scripts/build-dist.mjs
@@ -38,7 +38,8 @@ function copyFile(src, dest) {
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
} else {
- console.warn(`Warning: Source file ${src} does not exist.`);
+ console.error(`Error: Source file ${src} does not exist.`);
+ process.exit(1);
}
}
diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs
index 0a340dd7..6f3caf4c 100755
--- a/scripts/run-unit-tests.mjs
+++ b/scripts/run-unit-tests.mjs
@@ -4,6 +4,9 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
+// Ensure NODE_ENV is set to "test" so that nostrToolsBootstrap.js uses the mock toolkit
+process.env.NODE_ENV = "test";
+
const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
const testsDir = path.join(rootDir, "tests");
const testFiles = [];
diff --git a/tests/app-batch-fetch-profiles.test.mjs b/tests/app-batch-fetch-profiles.test.mjs
index f682cab3..95463f71 100644
--- a/tests/app-batch-fetch-profiles.test.mjs
+++ b/tests/app-batch-fetch-profiles.test.mjs
@@ -49,6 +49,9 @@ test("batchFetchProfiles handles fast and failing relays", async () => {
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: [];
+ const originalReadRelays = Array.isArray(nostrClient.readRelays)
+ ? [...nostrClient.readRelays]
+ : [];
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
@@ -60,6 +63,7 @@ test("batchFetchProfiles handles fast and failing relays", async () => {
const poolListCalls = [];
nostrClient.relays = [FAST_RELAY, FAIL_RELAY];
+ nostrClient.readRelays = [FAST_RELAY, FAIL_RELAY];
nostrClient.writeRelays = [FAST_RELAY, FAIL_RELAY];
nostrClient.readRelays = [FAST_RELAY, FAIL_RELAY];
nostrClient.pool = {
@@ -96,6 +100,7 @@ test("batchFetchProfiles handles fast and failing relays", async () => {
});
} finally {
nostrClient.relays = originalRelays;
+ nostrClient.readRelays = originalReadRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.readRelays = originalReadRelays;
nostrClient.pool = originalPool;
diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs
index 4b8fbf7b..06f79d4e 100644
--- a/tests/profile-modal-controller.test.mjs
+++ b/tests/profile-modal-controller.test.mjs
@@ -273,9 +273,11 @@ function createController(options = {}) {
}
return hashtagStore.disinterests.delete(normalized);
},
+ load: async () => {},
publish: async () => ({ ok: true }),
load: async () => {},
on: () => () => {},
+ load: async () => {},
};
const hashtagPreferences = {
diff --git a/tests/video-modal-controller.test.mjs b/tests/video-modal-controller.test.mjs
new file mode 100644
index 00000000..311d2c54
--- /dev/null
+++ b/tests/video-modal-controller.test.mjs
@@ -0,0 +1,113 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import VideoModalController from "../js/ui/videoModalController.js";
+
+test("VideoModalController", async (t) => {
+ await t.test("ensureVideoModalReady throws if modal is missing", async () => {
+ const controller = new VideoModalController({
+ getVideoModal: () => null,
+ });
+ await assert.rejects(
+ controller.ensureVideoModalReady(),
+ /Video modal instance is not available/
+ );
+ });
+
+ await t.test("ensureVideoModalReady loads modal if needs rehydrate", async () => {
+ let loadCalled = false;
+ let setVideoElementCalled = false;
+ const mockRoot = { isConnected: true };
+ const mockVideoElement = { isConnected: true };
+
+ const mockModal = {
+ // First call returns null (simulate missing root), subsequent calls return valid
+ getRoot: (() => {
+ let calls = 0;
+ return () => {
+ calls++;
+ return calls === 1 ? null : mockRoot;
+ };
+ })(),
+ getVideoElement: () => mockVideoElement,
+ load: async () => {
+ loadCalled = true;
+ },
+ setVideoElement: (el) => {
+ setVideoElementCalled = true;
+ assert.equal(el, mockVideoElement);
+ },
+ };
+
+ const controller = new VideoModalController({
+ getVideoModal: () => mockModal,
+ });
+
+ const result = await controller.ensureVideoModalReady();
+
+ assert.equal(loadCalled, true, "Should call load()");
+ assert.equal(setVideoElementCalled, true, "Should set video element");
+ assert.equal(result.root, mockRoot);
+ assert.equal(result.videoElement, mockVideoElement);
+ });
+
+ await t.test("showModalWithPoster uses provided video", async () => {
+ const mockVideo = { id: "v1" };
+ let openedVideo = null;
+ let appliedPoster = false;
+
+ const mockModal = {
+ getRoot: () => ({ isConnected: true }),
+ getVideoElement: () => ({ isConnected: true }),
+ load: async () => {},
+ open: (video) => { openedVideo = video; },
+ applyLoadingPoster: () => { appliedPoster = true; },
+ setVideoElement: () => {},
+ };
+
+ const controller = new VideoModalController({
+ getVideoModal: () => mockModal,
+ });
+
+ await controller.showModalWithPoster(mockVideo);
+ assert.deepEqual(openedVideo, mockVideo);
+ assert.equal(appliedPoster, true);
+ });
+
+ await t.test("showModalWithPoster falls back to current video", async () => {
+ const currentVideo = { id: "current" };
+ let openedVideo = null;
+
+ const mockModal = {
+ getRoot: () => ({ isConnected: true }),
+ getVideoElement: () => ({ isConnected: true }),
+ load: async () => {},
+ open: (video) => { openedVideo = video; },
+ applyLoadingPoster: () => {},
+ setVideoElement: () => {},
+ };
+
+ const controller = new VideoModalController({
+ getVideoModal: () => mockModal,
+ callbacks: {
+ getCurrentVideo: () => currentVideo,
+ }
+ });
+
+ await controller.showModalWithPoster(); // No video passed
+ assert.deepEqual(openedVideo, currentVideo);
+ });
+
+ await t.test("forceRemoveModalPoster calls modal method", async () => {
+ let removeReason = null;
+ const mockModal = {
+ forceRemovePoster: (reason) => { removeReason = reason; return true; }
+ };
+
+ const controller = new VideoModalController({
+ getVideoModal: () => mockModal,
+ });
+
+ controller.forceRemoveModalPoster("test-reason");
+ assert.equal(removeReason, "test-reason");
+ });
+});