3 Commits
18 changed files with 1309 additions and 20 deletions

No files matched your search

+7
View File
@@ -14,6 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
websockify \ websockify \
fluxbox \ fluxbox \
procps \ procps \
ca-certificates \
curl \
&& curl -fsSLo /tmp/google-chrome-stable_current_amd64.deb \
https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
&& apt-get install -y --no-install-recommends \
/tmp/google-chrome-stable_current_amd64.deb \
&& rm -f /tmp/google-chrome-stable_current_amd64.deb \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+2 -2
View File
@@ -1,11 +1,11 @@
import { createBrowserRouter, type RouteObject } from 'react-router-dom'; import { createBrowserRouter, type RouteObject } from 'react-router-dom';
import { AppShell } from './app-shell.js'; import { AppShell } from './app-shell.js';
import { OverviewPage } from '../features/overview/overview-page.js'; import { OverviewPage } from '../features/overview/overview-page.js';
import { PlaceholderPage } from '../features/placeholder-page.js';
import { ConversationRoute } from '../features/conversations/conversation-route.js'; import { ConversationRoute } from '../features/conversations/conversation-route.js';
import { MediaPage } from '../features/media/media-page.js'; import { MediaPage } from '../features/media/media-page.js';
import { OperationsPage } from '../features/operations/operations-page.js'; import { OperationsPage } from '../features/operations/operations-page.js';
import { PerformancePage } from '../features/performance/performance-page.js'; import { PerformancePage } from '../features/performance/performance-page.js';
import { SettingsRoute } from '../features/settings/settings-route.js';
export function createDashboardRoutes(): RouteObject[] { export function createDashboardRoutes(): RouteObject[] {
return [ return [
@@ -19,7 +19,7 @@ export function createDashboardRoutes(): RouteObject[] {
{ path: 'media', element: <MediaPage /> }, { path: 'media', element: <MediaPage /> },
{ path: 'performance', element: <PerformancePage /> }, { path: 'performance', element: <PerformancePage /> },
{ path: 'operations', element: <OperationsPage /> }, { path: 'operations', element: <OperationsPage /> },
{ path: 'settings', element: <PlaceholderPage title="Settings" description="Manage runtime behavior and Reddit access." /> }, { path: 'settings', element: <SettingsRoute /> },
], ],
}, },
]; ];
@@ -1,15 +0,0 @@
interface PlaceholderPageProps {
title: string;
description: string;
}
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
return (
<div className="page">
<header className="page-header">
<div><span className="eyebrow">Workspace</span><h1>{title}</h1><p>{description}</p></div>
</header>
<section className="panel empty-state"><strong>{title} workspace</strong><span>This route is ready for its feature module.</span></section>
</div>
);
}
@@ -0,0 +1,59 @@
import { useLoginInfoQuery, useLoginStatusQuery, useStartLoginMutation } from './settings-queries.js';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
if (!status) return 'Unknown';
if (!status.ready) return 'Browser unavailable';
if ('error' in status) return 'Check failed';
return {
unknown: 'Unknown',
authenticated: 'Authenticated',
'login-required': 'Login required',
unreachable: 'Reddit unreachable',
}[status.status];
}
function noVncUrl(publicUrl: string | null, port: number): string {
if (publicUrl) return publicUrl;
return `${window.location.protocol}//${window.location.hostname}:${port}/vnc.html?autoconnect=true&resize=scale`;
}
export function AccessPanel() {
const infoQuery = useLoginInfoQuery();
const statusQuery = useLoginStatusQuery();
const loginMutation = useStartLoginMutation();
if (infoQuery.isPending || statusQuery.isPending) {
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access…</section>;
}
if (infoQuery.isError || statusQuery.isError) {
const error = infoQuery.error ?? statusQuery.error;
return (
<section className="panel error-panel" role="alert">
<strong>Reddit access unavailable</strong><span>{errorMessage(error)}</span>
<button className="button secondary" onClick={() => void Promise.all([infoQuery.refetch(), statusQuery.refetch()])} type="button">Retry access</button>
</section>
);
}
const info = infoQuery.data;
return (
<section aria-labelledby="access-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Browser session</span><h2 id="access-heading">Access</h2></div><span className={statusQuery.data.ready && 'status' in statusQuery.data && statusQuery.data.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(statusQuery.data)}</span></div>
<p className="settings-copy">Open Reddit in the managed browser, complete login through noVNC, then recheck the session. Credentials are never entered or displayed here.</p>
<div className="access-actions">
<button className="button primary" disabled={loginMutation.isPending} onClick={() => loginMutation.mutate()} type="button">{loginMutation.isPending ? 'Opening login…' : 'Open Reddit login'}</button>
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking session…' : 'Recheck session'}</button>
{info.vncEnabled ? <a className="button secondary" href={noVncUrl(info.publicUrl, info.novncPort)} rel="noreferrer" target="_blank">Open noVNC login window</a> : null}
</div>
{!info.vncEnabled ? <div className="settings-notice warning" role="status">noVNC is not available in this deployment.</div> : null}
{info.passwordRequired ? <p className="settings-copy">A VNC password is required.</p> : null}
{loginMutation.isSuccess ? <div className="settings-notice success" role="status">Login page opened.</div> : null}
{loginMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(loginMutation.error)}</div> : null}
</section>
);
}
@@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react';
interface RiskDialogProps {
reasons: string[];
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="risk-settings-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm operational risk</span>
<h2 id="risk-settings-title">Confirm risky settings</h2>
<ul className="risk-list">
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : 'Apply risky settings'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import type { ZodType } from 'zod';
import {
ConfigResponseSchema,
ConfigUpdateRequestSchema,
ConfigUpdateResponseSchema,
ErrorResponseSchema,
LoginInfoResponseSchema,
LoginStartResponseSchema,
LoginStatusResponseSchema,
type ConfigResponse,
type ConfigUpdateRequest,
type LoginStatusResponse,
} from '../../../../../packages/api-contracts/src/index.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const parsedError = ErrorResponseSchema.safeParse(payload);
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
}
return schema.parse(payload);
}
function request(path: string, init?: RequestInit): Promise<Response> {
return globalThis.fetch(path, { credentials: 'same-origin', ...init });
}
export function fetchSettings(): Promise<ConfigResponse> {
return request('/api/config').then((response) => parseResponse(response, ConfigResponseSchema));
}
export async function saveSettings(updates: ConfigUpdateRequest) {
const body = ConfigUpdateRequestSchema.parse(updates);
return parseResponse(
await request('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
ConfigUpdateResponseSchema,
);
}
export function fetchLoginInfo() {
return request('/api/login/info').then((response) => parseResponse(response, LoginInfoResponseSchema));
}
export function fetchLoginStatus(): Promise<LoginStatusResponse> {
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
}
export async function startLogin(): Promise<void> {
const response = await parseResponse(
await request('/api/login/start', { method: 'POST' }),
LoginStartResponseSchema,
);
if (!response.ok) throw new Error(response.error);
}
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
import { AccessPanel } from './access-panel.js';
import { RiskDialog } from './risk-dialog.js';
import { useSaveSettingsMutation } from './settings-queries.js';
import { createSettingsDraft, isDraftDirty, validateSettingsDraft, type SettingsDraft } from './settings-model.js';
import { BehaviourSection, MonitoringSection, RateLimitsSection } from './settings-sections.js';
import type { SettingsSection } from './settings-page.js';
interface SettingsEditorProps {
config: ConfigResponse;
section: SettingsSection;
}
interface PendingRiskySave {
payload: ConfigUpdateRequest;
reasons: string[];
}
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
export function SettingsEditor({ config, section }: SettingsEditorProps) {
const [persistedDraft, setPersistedDraft] = useState<SettingsDraft>(() => createSettingsDraft(config));
const [draft, setDraft] = useState<SettingsDraft>(persistedDraft);
const [errors, setErrors] = useState<Record<string, string>>({});
const [pendingRiskySave, setPendingRiskySave] = useState<PendingRiskySave | null>(null);
const saveMutation = useSaveSettingsMutation();
const isDirty = isDraftDirty(draft, persistedDraft);
useEffect(() => {
function warnBeforeUnload(event: BeforeUnloadEvent) {
if (!isDirty) return;
event.preventDefault();
event.returnValue = '';
}
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isDirty]);
function applySavedResponse(response: Awaited<ReturnType<typeof saveMutation.mutateAsync>>) {
const nextConfig: ConfigResponse = {
...response.config,
subreddits: response.subreddits,
tones: config.tones,
};
const nextDraft = createSettingsDraft(nextConfig);
setPersistedDraft(nextDraft);
setDraft(nextDraft);
setPendingRiskySave(null);
}
function submit(payload: ConfigUpdateRequest) {
saveMutation.mutate(payload, { onSuccess: applySavedResponse });
}
function updateDraft(nextDraft: SettingsDraft) {
saveMutation.reset();
setErrors({});
setDraft(nextDraft);
}
function prepareSave() {
saveMutation.reset();
const validation = validateSettingsDraft(draft);
setErrors(validation.errors);
if (!validation.data) return;
const reasons: string[] = [];
if (persistedDraft.dryRun && !validation.data.dryRun) {
reasons.push('Automated messages will be sent to Reddit because dry run mode is being disabled.');
}
const persistedLimit = Number(persistedDraft.maxMessagesPerHour);
if (validation.data.maxMessagesPerHour > 20 && validation.data.maxMessagesPerHour > persistedLimit) {
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
}
if (reasons.length > 0) {
setPendingRiskySave({ payload: validation.data.payload, reasons });
return;
}
submit(validation.data.payload);
}
return (
<>
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'rate-limits' ? <RateLimitsSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'behaviour' ? <BehaviourSection config={config} draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'access' ? <AccessPanel /> : null}
{section !== 'access' ? (
<div className="settings-save-bar">
<div aria-live="polite">
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
{saveMutation.isSuccess ? <strong className="save-success">Settings saved.</strong> : null}
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
</div>
<div className="header-actions">
<button className="button secondary" disabled={!isDirty || saveMutation.isPending} onClick={() => { setDraft(persistedDraft); setErrors({}); saveMutation.reset(); }} type="button">Reset changes</button>
<button aria-label={saveMutation.isPending ? 'Saving settings' : 'Save settings'} className="button primary" disabled={!isDirty || saveMutation.isPending} onClick={prepareSave} type="button">{saveMutation.isPending ? 'Saving…' : 'Save settings'}</button>
</div>
</div>
) : null}
{pendingRiskySave ? (
<RiskDialog
isPending={saveMutation.isPending}
onCancel={() => setPendingRiskySave(null)}
onConfirm={() => submit(pendingRiskySave.payload)}
reasons={pendingRiskySave.reasons}
/>
) : null}
</>
);
}
@@ -0,0 +1,142 @@
import { z } from 'zod';
import {
ConfigUpdateRequestSchema,
SubredditConfigSchema,
ToneKeySchema,
type ConfigResponse,
type ConfigUpdateRequest,
} from '../../../../../packages/api-contracts/src/index.js';
export interface SubredditDraft {
name: string;
type: 'image' | 'chat';
weight: string;
tone: z.infer<typeof ToneKeySchema>;
scripted: string;
}
export interface SettingsDraft {
minDelayMinutes: string;
maxMessagesPerHour: string;
jitterSeconds: string;
dryRun: boolean;
prioritizeRecentPosts: boolean;
recentWindowMinutes: string;
subreddits: SubredditDraft[];
}
export interface ValidatedSettings {
payload: ConfigUpdateRequest;
dryRun: boolean;
maxMessagesPerHour: number;
}
export interface SettingsValidationResult {
data?: ValidatedSettings;
errors: Record<string, string>;
}
const EditableSubredditSchema = SubredditConfigSchema.extend({
name: z.string().trim().min(1, 'Subreddit name is required.'),
weight: z.number().positive('Subreddit weight must be greater than 0.').optional(),
});
const ValidatedSettingsSchema = z.strictObject({
minDelayMinutes: z.number().finite().min(0, 'Minimum delay cannot be negative.'),
maxMessagesPerHour: z.number().int().min(1, 'Maximum messages per hour must be at least 1.'),
jitterSeconds: z.number().finite().min(0, 'Jitter cannot be negative.'),
dryRun: z.boolean(),
prioritizeRecentPosts: z.boolean(),
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'),
}).superRefine((value, context) => {
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase());
if (new Set(names).size !== names.length) {
context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' });
}
});
export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
return {
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
dryRun: config.browser.dryRun,
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
subreddits: config.subreddits.map((subreddit) => ({
name: subreddit.name,
type: subreddit.type,
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted ?? '',
})),
};
}
export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationResult {
const requiredErrors: Record<string, string> = {};
if (draft.minDelayMinutes.trim() === '') requiredErrors.minDelayMinutes = 'Minimum delay is required.';
if (draft.maxMessagesPerHour.trim() === '') requiredErrors.maxMessagesPerHour = 'Maximum messages per hour is required.';
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window is required.';
draft.subreddits.forEach((subreddit, index) => {
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.';
}
});
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
const parsed = ValidatedSettingsSchema.safeParse({
minDelayMinutes: Number(draft.minDelayMinutes),
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
jitterSeconds: Number(draft.jitterSeconds),
dryRun: draft.dryRun,
prioritizeRecentPosts: draft.prioritizeRecentPosts,
recentWindowMinutes: Number(draft.recentWindowMinutes),
subreddits: draft.subreddits.map((subreddit) => ({
name: subreddit.name.trim(),
type: subreddit.type,
weight: subreddit.weight.trim() === '' ? undefined : Number(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted,
})),
});
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const path = issue.path.join('.');
if (!(path in errors)) errors[path] = issue.message;
}
return { errors };
}
const payload = ConfigUpdateRequestSchema.parse({
rateLimit: {
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000,
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
jitterMs: parsed.data.jitterSeconds * 1_000,
},
browser: { dryRun: parsed.data.dryRun },
reddit: {
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
recentWindowMinutes: parsed.data.recentWindowMinutes,
},
subreddits: parsed.data.subreddits,
});
return {
data: {
payload,
dryRun: parsed.data.dryRun,
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
},
errors: {},
};
}
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean {
return JSON.stringify(draft) !== JSON.stringify(persisted);
}
@@ -0,0 +1,44 @@
import { SettingsEditor } from './settings-editor.js';
import { useSettingsQuery } from './settings-queries.js';
import './settings.css';
export type SettingsSection = 'monitoring' | 'rate-limits' | 'behaviour' | 'access';
interface SettingsPageProps {
section: SettingsSection;
onSectionChange(section: SettingsSection): void;
}
const sections: ReadonlyArray<{ id: SettingsSection; label: string; description: string }> = [
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
{ id: 'access', label: 'Access', description: 'Reddit browser session' },
];
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
export function SettingsPage({ section, onSectionChange }: SettingsPageProps) {
const settingsQuery = useSettingsQuery();
return (
<div className="page settings-page">
<header className="page-header">
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit browser access.</p></div>
</header>
<nav aria-label="Settings sections" className="settings-navigation">
{sections.map((item) => (
<button aria-current={section === item.id ? 'page' : undefined} aria-label={item.label} className={section === item.id ? 'is-active' : ''} key={item.id} onClick={() => onSectionChange(item.id)} type="button"><strong>{item.label}</strong><small>{item.description}</small></button>
))}
</nav>
{settingsQuery.isPending ? <div aria-label="Loading settings" className="page-state settings-loading" role="status"><span className="spinner" />Loading settings…</div> : null}
{settingsQuery.isError ? (
<div className="page-state error-state settings-loading" role="alert"><strong>Settings unavailable</strong><span>{errorMessage(settingsQuery.error)}</span><button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry settings</button></div>
) : null}
{settingsQuery.data ? <SettingsEditor config={settingsQuery.data} section={section} /> : null}
</div>
);
}
@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { fetchLoginInfo, fetchLoginStatus, fetchSettings, saveSettings, startLogin } from './settings-api.js';
export const settingsQueryKeys = {
config: ['dashboard', 'config'] as const,
loginInfo: ['dashboard', 'login', 'info'] as const,
loginStatus: ['dashboard', 'login', 'status'] as const,
};
export function useSettingsQuery() {
return useQuery({ queryKey: settingsQueryKeys.config, queryFn: fetchSettings });
}
export function useSaveSettingsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: saveSettings,
onSuccess(response) {
queryClient.setQueryData<ConfigResponse>(settingsQueryKeys.config, (current) => ({
...response.config,
subreddits: response.subreddits,
tones: current?.tones ?? [],
}));
},
});
}
export function useLoginInfoQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginInfo, queryFn: fetchLoginInfo });
}
export function useLoginStatusQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginStatus, queryFn: fetchLoginStatus });
}
export function useStartLoginMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: startLogin,
onSuccess() {
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus });
},
});
}
@@ -0,0 +1,19 @@
import { useSearchParams } from 'react-router-dom';
import { SettingsPage, type SettingsSection } from './settings-page.js';
const validSections: readonly SettingsSection[] = ['monitoring', 'rate-limits', 'behaviour', 'access'];
function parseSection(value: string | null): SettingsSection {
return validSections.find((section) => section === value) ?? 'monitoring';
}
export function SettingsRoute() {
const [searchParams, setSearchParams] = useSearchParams();
const section = parseSection(searchParams.get('section'));
function selectSection(nextSection: SettingsSection) {
setSearchParams({ section: nextSection }, { replace: true });
}
return <SettingsPage onSectionChange={selectSection} section={section} />;
}
@@ -0,0 +1,209 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import type { SettingsDraft, SubredditDraft } from './settings-model.js';
interface SectionProps {
draft: SettingsDraft;
errors: Record<string, string>;
onChange(next: SettingsDraft): void;
}
interface NumberFieldProps {
error?: string;
label: string;
name: string;
min?: number;
value: string;
onChange(value: string): void;
}
function NumberField({ error, label, name, min, value, onChange }: NumberFieldProps) {
const errorId = `${name}-error`;
return (
<label className="settings-field">
<span>{label}</span>
<input
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? 'true' : undefined}
aria-label={label}
min={min}
name={name}
onChange={(event) => onChange(event.target.value)}
step="1"
type="number"
value={value}
/>
{error ? <small className="field-error" id={errorId}>{error}</small> : null}
</label>
);
}
export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
return (
<section aria-labelledby="monitoring-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Discovery</span><h2 id="monitoring-heading">Monitoring</h2></div></div>
<label className="toggle-field">
<input
aria-label="Prioritise recent posts"
checked={draft.prioritizeRecentPosts}
onChange={(event) => onChange({ ...draft, prioritizeRecentPosts: event.target.checked })}
type="checkbox"
/>
<span><strong>Prioritise recent posts</strong><small>Prefer posts inside the configured recent window.</small></span>
</label>
<NumberField
error={errors.recentWindowMinutes}
label="Recent window in minutes"
min={1}
name="recent-window"
onChange={(recentWindowMinutes) => onChange({ ...draft, recentWindowMinutes })}
value={draft.recentWindowMinutes}
/>
</section>
);
}
export function RateLimitsSection({ draft, errors, onChange }: SectionProps) {
return (
<section aria-labelledby="rate-limits-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Sending safeguards</span><h2 id="rate-limits-heading">Rate limits</h2></div></div>
<div className="settings-grid">
<NumberField
error={errors.minDelayMinutes}
label="Minimum delay in minutes"
min={0}
name="minimum-delay"
onChange={(minDelayMinutes) => onChange({ ...draft, minDelayMinutes })}
value={draft.minDelayMinutes}
/>
<NumberField
error={errors.maxMessagesPerHour}
label="Maximum messages per hour"
min={1}
name="hourly-limit"
onChange={(maxMessagesPerHour) => onChange({ ...draft, maxMessagesPerHour })}
value={draft.maxMessagesPerHour}
/>
<NumberField
error={errors.jitterSeconds}
label="Random jitter in seconds"
min={0}
name="jitter"
onChange={(jitterSeconds) => onChange({ ...draft, jitterSeconds })}
value={draft.jitterSeconds}
/>
</div>
</section>
);
}
function updateSubreddit(draft: SettingsDraft, index: number, next: SubredditDraft): SettingsDraft {
return { ...draft, subreddits: draft.subreddits.map((item, itemIndex) => itemIndex === index ? next : item) };
}
export function BehaviourSection({ config, draft, errors, onChange }: SectionProps & { config: ConfigResponse }) {
return (
<section aria-labelledby="behaviour-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Runtime behaviour</span><h2 id="behaviour-heading">Behaviour</h2></div></div>
<label className="toggle-field">
<input
aria-label="Dry run mode"
checked={draft.dryRun}
onChange={(event) => onChange({ ...draft, dryRun: event.target.checked })}
type="checkbox"
/>
<span><strong>Dry run mode</strong><small>Evaluate posts without sending messages.</small></span>
</label>
<div className="subreddit-heading">
<div><h3>Subreddits</h3><p>Configure monitored communities and their supported message style.</p></div>
<button
className="button secondary"
onClick={() => onChange({
...draft,
subreddits: [
...draft.subreddits,
{ name: '', type: 'image', weight: '1', tone: 'default', scripted: '' },
],
})}
type="button"
>
Add subreddit
</button>
</div>
{errors.subreddits ? <p className="field-error" role="alert">{errors.subreddits}</p> : null}
<div className="subreddit-list">
{draft.subreddits.map((subreddit, index) => (
<fieldset className="subreddit-row" key={index}>
<legend>Subreddit {index + 1}</legend>
<label className="settings-field">
<span>Name</span>
<input
aria-describedby={errors[`subreddits.${index}.name`] ? `subreddit-${index}-name-error` : undefined}
aria-invalid={errors[`subreddits.${index}.name`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} name`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, name: event.target.value }))}
type="text"
value={subreddit.name}
/>
{errors[`subreddits.${index}.name`] ? <small className="field-error" id={`subreddit-${index}-name-error`}>{errors[`subreddits.${index}.name`]}</small> : null}
</label>
<label className="settings-field">
<span>Type</span>
<select
aria-label={`Subreddit ${index + 1} type`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, type: event.target.value as SubredditDraft['type'] }))}
value={subreddit.type}
>
<option value="image">Image</option><option value="chat">Chat</option>
</select>
</label>
<label className="settings-field">
<span>Weight</span>
<input
aria-describedby={errors[`subreddits.${index}.weight`] ? `subreddit-${index}-weight-error` : undefined}
aria-invalid={errors[`subreddits.${index}.weight`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} weight`}
min="0.01"
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, weight: event.target.value }))}
step="0.01"
type="number"
value={subreddit.weight}
/>
{errors[`subreddits.${index}.weight`] ? <small className="field-error" id={`subreddit-${index}-weight-error`}>{errors[`subreddits.${index}.weight`]}</small> : null}
</label>
<label className="settings-field">
<span>Tone</span>
<select
aria-label={`Subreddit ${index + 1} tone`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, tone: event.target.value as SubredditDraft['tone'] }))}
value={subreddit.tone}
>
{config.tones.map((tone) => <option key={tone} value={tone}>{tone}</option>)}
</select>
</label>
<label className="settings-field scripted-field">
<span>Scripted message (optional)</span>
<textarea
aria-label={`Subreddit ${index + 1} scripted message`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, scripted: event.target.value }))}
rows={2}
value={subreddit.scripted}
/>
</label>
<button
className="button secondary remove-subreddit"
disabled={draft.subreddits.length === 1}
onClick={() => onChange({
...draft,
subreddits: draft.subreddits.filter((_, itemIndex) => itemIndex !== index),
})}
type="button"
>
Remove subreddit {index + 1}
</button>
</fieldset>
))}
</div>
</section>
);
}
@@ -0,0 +1,48 @@
.settings-page { max-width: 72rem; }
.settings-navigation { display: grid; grid-template-columns: 1fr; gap: .5rem; margin-bottom: 1rem; }
.settings-navigation button { display: grid; gap: .15rem; padding: .8rem; color: var(--muted); text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; }
.settings-navigation button strong { color: #e9f1ec; }
.settings-navigation button small { font-size: .72rem; }
.settings-navigation button.is-active { background: var(--accent-dark); border-color: #28593d; }
.settings-navigation button:focus-visible, .settings-field input:focus-visible, .settings-field select:focus-visible, .settings-field textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.settings-loading { min-height: 24rem; }
.settings-section { display: grid; gap: 1rem; }
.settings-copy { color: var(--muted); }
.settings-grid { display: grid; gap: 1rem; }
.settings-field { display: grid; gap: .4rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
.settings-field input, .settings-field select, .settings-field textarea { width: 100%; min-height: 2.7rem; padding: .55rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
.settings-field textarea { resize: vertical; }
.settings-field [aria-invalid="true"] { border-color: var(--danger); }
.field-error, .save-error { color: var(--danger); }
.toggle-field { display: flex; align-items: flex-start; gap: .8rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.toggle-field input { width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
.toggle-field span { display: grid; gap: .2rem; }
.toggle-field small { color: var(--muted); }
.subreddit-heading { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; padding-top: .5rem; }
.subreddit-heading h3 { margin: 0 0 .25rem; }
.subreddit-heading p { margin: 0; color: var(--muted); }
.subreddit-list { display: grid; gap: .75rem; }
.subreddit-row { display: grid; gap: .75rem; min-width: 0; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; }
.subreddit-row legend { padding: 0 .4rem; color: var(--accent); font-weight: 800; }
.remove-subreddit { align-self: end; }
.settings-save-bar { position: sticky; bottom: 1rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--border); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .3); }
.settings-save-bar > div:first-child { display: grid; gap: .15rem; color: var(--muted); }
.save-success { color: var(--accent); }
.risk-list { display: grid; gap: .5rem; padding-left: 1.2rem; color: var(--warning); }
.access-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
.access-actions a { display: inline-flex; align-items: center; text-decoration: none; }
.settings-notice { padding: .75rem; border-radius: .65rem; }
.settings-notice.success { color: #aaf0c4; background: #163a26; }
.settings-notice.warning { color: #ffe1a0; background: #42351b; }
.settings-notice.error { color: #ffd1ce; background: #461b18; }
@media (min-width: 42rem) {
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.subreddit-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.scripted-field { grid-column: 1 / -1; }
}
@media (min-width: 70rem) {
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
+54
View File
@@ -9,6 +9,9 @@ VNC_PORT="${VNC_PORT:-5900}"
NOVNC_PORT="${NOVNC_PORT:-6080}" NOVNC_PORT="${NOVNC_PORT:-6080}"
GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}" GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}"
BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}" BROWSER_SESSION_DIR="${BROWSER_SESSION_DIR:-/app/data/browser-session}"
CHROME_CDP_PORT="${CHROME_CDP_PORT:-9222}"
GOOGLE_CHROME_BIN="${GOOGLE_CHROME_BIN:-google-chrome}"
export CHROME_CDP_URL="${CHROME_CDP_URL:-http://127.0.0.1:${CHROME_CDP_PORT}}"
export DISPLAY=":${DISPLAY_NUM}" export DISPLAY=":${DISPLAY_NUM}"
# noVNC ships under different paths depending on the distro; find the web root once. # noVNC ships under different paths depending on the distro; find the web root once.
@@ -104,8 +107,59 @@ websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \
>/var/log/websockify.log 2>&1 & >/var/log/websockify.log 2>&1 &
NOVNC_PID=$! NOVNC_PID=$!
# Start the installed consumer Google Chrome directly. Playwright attaches only
# after Chrome is running, so the human login is not performed in a browser
# process launched with Playwright's automation arguments.
CHROME_PID=""
CHROME_READY=0
if command -v "${GOOGLE_CHROME_BIN}" >/dev/null 2>&1; then
echo "[entrypoint] starting Google Chrome for interactive login"
"${GOOGLE_CHROME_BIN}" \
--user-data-dir="${BROWSER_SESSION_DIR}" \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port="${CHROME_CDP_PORT}" \
--no-first-run \
--no-default-browser-check \
--disable-dev-shm-usage \
--no-sandbox \
--start-maximized \
about:blank \
>/var/log/google-chrome.log 2>&1 &
CHROME_PID=$!
for i in $(seq 1 100); do
if ! kill -0 "${CHROME_PID}" 2>/dev/null; then
break
fi
if curl -fsS "${CHROME_CDP_URL}/json/version" >/dev/null 2>&1; then
echo "[entrypoint] Google Chrome is ready after ${i} checks"
CHROME_READY=1
break
fi
sleep 0.2
done
fi
if [ "${CHROME_READY}" != "1" ]; then
echo "[entrypoint] WARNING: Google Chrome failed to start; using Playwright fallback" >&2
if [ -n "${CHROME_PID}" ]; then
kill "${CHROME_PID}" 2>/dev/null || true
wait "${CHROME_PID}" 2>/dev/null || true
fi
rm -f \
"${BROWSER_SESSION_DIR}/SingletonLock" \
"${BROWSER_SESSION_DIR}/SingletonSocket" \
"${BROWSER_SESSION_DIR}/SingletonCookie" \
2>/dev/null || true
CHROME_PID=""
unset CHROME_CDP_URL
fi
cleanup() { cleanup() {
echo "[entrypoint] shutting down" echo "[entrypoint] shutting down"
if [ -n "${CHROME_PID}" ]; then
kill "${CHROME_PID}" 2>/dev/null || true
fi
kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true
wait || true wait || true
} }
+69 -3
View File
@@ -4,6 +4,25 @@ import type { Logger } from '../utils/logger.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
export interface ConnectedBrowser {
contexts(): BrowserContext[];
close(): Promise<void>;
}
export interface BrowserRuntime {
connectOverCDP(endpoint: string): Promise<ConnectedBrowser>;
launchPersistentContext(
userDataDir: string,
options: Parameters<typeof chromium.launchPersistentContext>[1]
): Promise<BrowserContext>;
}
const defaultBrowserRuntime: BrowserRuntime = {
connectOverCDP: (endpoint) => chromium.connectOverCDP(endpoint),
launchPersistentContext: (userDataDir, options) =>
chromium.launchPersistentContext(userDataDir, options),
};
export type RedditSessionState = export type RedditSessionState =
| 'unknown' | 'unknown'
| 'authenticated' | 'authenticated'
@@ -19,13 +38,16 @@ export function classifyRedditSessionError(error: unknown): Exclude<RedditSessio
export class BrowserClient { export class BrowserClient {
private context: BrowserContext | null = null; private context: BrowserContext | null = null;
private connectedBrowser: ConnectedBrowser | null = null;
private usingExternalChrome = false;
private sessionDir: string | null = null; private sessionDir: string | null = null;
private sessionValidityInFlight: Promise<boolean> | null = null; private sessionValidityInFlight: Promise<boolean> | null = null;
private sessionState: RedditSessionState = 'unknown'; private sessionState: RedditSessionState = 'unknown';
constructor( constructor(
private config: Config, private config: Config,
private logger: Logger private logger: Logger,
private browserRuntime: BrowserRuntime = defaultBrowserRuntime
) { } ) { }
async initialize(): Promise<BrowserContext> { async initialize(): Promise<BrowserContext> {
@@ -37,10 +59,31 @@ export class BrowserClient {
fs.mkdirSync(sessionDir, { recursive: true }); fs.mkdirSync(sessionDir, { recursive: true });
} }
this.logger.info(`Launching browser with session dir: ${sessionDir}`); const cdpEndpoint = process.env.CHROME_CDP_URL?.trim();
if (cdpEndpoint) {
try {
this.logger.info(`Attaching to independently launched Google Chrome at ${cdpEndpoint}`);
const browser = await this.browserRuntime.connectOverCDP(cdpEndpoint);
const context = browser.contexts()[0];
if (!context) {
await browser.close().catch(() => undefined);
throw new Error('External Chrome did not expose a default browser context');
}
this.connectedBrowser = browser;
this.usingExternalChrome = true;
this.context = context;
this.logger.info('Attached to Google Chrome; interactive login is available through VNC');
return context;
} catch (error) {
this.logger.warn('Could not attach to Google Chrome; falling back to Playwright browser launch:', error);
}
}
this.logger.info(`Launching fallback Playwright browser with session dir: ${sessionDir}`);
// Use launchPersistentContext to maintain cookies/localStorage // Use launchPersistentContext to maintain cookies/localStorage
this.context = await chromium.launchPersistentContext(sessionDir, { this.context = await this.browserRuntime.launchPersistentContext(sessionDir, {
headless: this.config.browser.headless, headless: this.config.browser.headless,
viewport: { width: 1280, height: 720 }, viewport: { width: 1280, height: 720 },
userAgent: this.config.reddit.userAgent, userAgent: this.config.reddit.userAgent,
@@ -70,6 +113,15 @@ export class BrowserClient {
} }
async close(): Promise<void> { async close(): Promise<void> {
if (this.connectedBrowser) {
this.logger.info('Closing Google Chrome connection');
await this.connectedBrowser.close();
this.connectedBrowser = null;
this.context = null;
this.usingExternalChrome = false;
return;
}
if (this.context) { if (this.context) {
this.logger.info('Closing browser'); this.logger.info('Closing browser');
await this.context.close(); await this.context.close();
@@ -150,6 +202,20 @@ export class BrowserClient {
} }
async login(): Promise<boolean> { async login(): Promise<boolean> {
if (this.usingExternalChrome) {
try {
const page = await this.getPage();
await page.bringToFront();
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
this.sessionState = 'login-required';
this.logger.warn('Interactive Reddit login required. Open the VNC page and sign in using Google Chrome.');
} catch (error) {
this.sessionState = classifyRedditSessionError(error);
this.logger.error('Failed to open Google Chrome for interactive login:', error);
}
return false;
}
if (!this.config.reddit.username || !this.config.reddit.password) { if (!this.config.reddit.username || !this.config.reddit.password) {
this.sessionState = 'login-required'; this.sessionState = 'login-required';
this.logger.error('Login failed: Missing credentials in config/env'); this.logger.error('Login failed: Missing credentials in config/env');
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import {
BrowserClient,
type BrowserRuntime,
type ConnectedBrowser,
} from '../src/browser/client.js';
import type { Logger } from '../src/utils/logger.js';
import { getTestConfig } from './testUtils.js';
function createLogger(): Logger {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as Logger;
}
afterEach(() => {
delete process.env.CHROME_CDP_URL;
});
describe('BrowserClient external Chrome', () => {
it('attaches to independently launched Chrome before considering Playwright launch', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const context = { pages: () => [] } as unknown as BrowserContext;
const connectedBrowser = {
contexts: () => [context],
close: vi.fn(),
} as unknown as ConnectedBrowser;
const runtime: BrowserRuntime = {
connectOverCDP: vi.fn().mockResolvedValue(connectedBrowser),
launchPersistentContext: vi.fn(),
};
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
const result = await client.initialize();
expect(result).toBe(context);
expect(runtime.connectOverCDP).toHaveBeenCalledWith('http://127.0.0.1:9222');
expect(runtime.launchPersistentContext).not.toHaveBeenCalled();
});
it('falls back to Playwright only when external Chrome cannot be reached', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const fallbackContext = { pages: () => [] } as unknown as BrowserContext;
const runtime: BrowserRuntime = {
connectOverCDP: vi.fn().mockRejectedValue(new Error('connection refused')),
launchPersistentContext: vi.fn().mockResolvedValue(fallbackContext),
};
const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
const result = await client.initialize();
expect(result).toBe(fallbackContext);
expect(runtime.launchPersistentContext).toHaveBeenCalledOnce();
});
it('opens the login page for a human without submitting credentials', async () => {
process.env.CHROME_CDP_URL = 'http://127.0.0.1:9222';
const page = {
goto: vi.fn().mockResolvedValue(undefined),
bringToFront: vi.fn().mockResolvedValue(undefined),
fill: vi.fn(),
} as unknown as Page;
const context = { pages: () => [page] } as unknown as BrowserContext;
const runtime: BrowserRuntime = {
connectOverCDP: vi.fn().mockResolvedValue({
contexts: () => [context],
close: vi.fn(),
}),
launchPersistentContext: vi.fn(),
};
const client = new BrowserClient(getTestConfig({
reddit: { username: 'human', password: 'secret' },
}), createLogger(), runtime);
await client.initialize();
await expect(client.login()).resolves.toBe(false);
expect(page.bringToFront).toHaveBeenCalledOnce();
expect(page.goto).toHaveBeenCalledWith(
'https://www.reddit.com/login',
{ waitUntil: 'domcontentloaded' },
);
expect(page.fill).not.toHaveBeenCalled();
expect(client.getSessionState()).toBe('login-required');
});
});
+291
View File
@@ -0,0 +1,291 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settings-route.js';
const config = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
};
const server = setupServer();
function installConfigApi() {
server.use(http.get('/api/config', () => HttpResponse.json(config)));
}
function renderSettings(path = '/settings') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter(
[{ path: '/settings', element: <SettingsRoute /> }],
{ initialEntries: [path] },
);
const view = render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
return { ...view, queryClient, router };
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('settings and access', () => {
it('loads the requested route-friendly section and keeps section selection in the URL', async () => {
let resolveConfig: ((response: Response) => void) | undefined;
server.use(
http.get('/api/config', async ({ request }) => {
expect(request.credentials).toBe('same-origin');
return new Promise<Response>((resolve) => { resolveConfig = resolve; });
}),
);
const user = userEvent.setup();
const { router } = renderSettings('/settings?section=rate-limits');
expect(screen.getByRole('status', { name: 'Loading settings' })).toBeInTheDocument();
await waitFor(() => expect(resolveConfig).toBeTypeOf('function'));
resolveConfig?.(HttpResponse.json(config));
expect(await screen.findByRole('heading', { name: 'Rate limits' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Monitoring' }));
expect(screen.getByRole('heading', { name: 'Monitoring' })).toBeInTheDocument();
expect(router.state.location.search).toBe('?section=monitoring');
});
it('shows a recoverable initial configuration error', async () => {
let requests = 0;
server.use(
http.get('/api/config', () => {
requests += 1;
return requests === 1
? HttpResponse.json({ error: 'Configuration unavailable' }, { status: 503 })
: HttpResponse.json(config);
}),
);
const user = userEvent.setup();
renderSettings();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Configuration unavailable');
await user.click(within(alert).getByRole('button', { name: 'Retry settings' }));
expect(await screen.findByRole('heading', { name: 'Monitoring' })).toBeInTheDocument();
});
it('validates fields, warns about unsaved changes, and resets to persisted values', async () => {
installConfigApi();
let updateRequests = 0;
server.use(http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({ success: true, config, subreddits: config.subreddits });
}));
const user = userEvent.setup();
renderSettings('/settings?section=monitoring');
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
await user.clear(recentWindow);
await user.type(recentWindow, '0');
expect(screen.getByRole('status')).toHaveTextContent('You have unsaved changes.');
const beforeUnload = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(beforeUnload);
expect(beforeUnload.defaultPrevented).toBe(true);
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Recent window must be greater than 0 minutes.')).toBeInTheDocument();
expect(recentWindow).toHaveAttribute('aria-invalid', 'true');
expect(updateRequests).toBe(0);
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
expect(recentWindow).toHaveValue(60);
expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument();
});
it('rejects blank numeric controls and invalid subreddit fields before transport', async () => {
installConfigApi();
let updateRequests = 0;
server.use(http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({ success: true });
}));
const user = userEvent.setup();
const { router } = renderSettings('/settings?section=rate-limits');
const minimumDelay = await screen.findByRole('spinbutton', { name: 'Minimum delay in minutes' });
await user.clear(minimumDelay);
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Minimum delay is required.')).toBeInTheDocument();
expect(minimumDelay).toHaveAttribute('aria-invalid', 'true');
await user.click(screen.getByRole('button', { name: 'Behaviour' }));
expect(router.state.location.search).toBe('?section=behaviour');
const subredditName = screen.getByRole('textbox', { name: 'Subreddit 1 name' });
await user.clear(subredditName);
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Subreddit name is required.')).toBeInTheDocument();
expect(subredditName).toHaveAttribute('aria-invalid', 'true');
await user.type(subredditName, 'example');
const weight = screen.getByRole('spinbutton', { name: 'Subreddit 1 weight' });
await user.clear(weight);
await user.type(weight, '-1');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Subreddit weight must be greater than 0.')).toBeInTheDocument();
expect(weight).toHaveAttribute('aria-invalid', 'true');
expect(updateRequests).toBe(0);
});
it('saves validated settings with pending and authoritative success states', async () => {
installConfigApi();
let requestBody: unknown;
let resolveUpdate: ((response: Response) => void) | undefined;
server.use(
http.put('/api/config', async ({ request }) => {
expect(request.credentials).toBe('same-origin');
requestBody = await request.json();
return new Promise<Response>((resolve) => { resolveUpdate = resolve; });
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=monitoring');
const prioritize = await screen.findByRole('checkbox', { name: 'Prioritise recent posts' });
await user.click(prioritize);
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByRole('button', { name: 'Saving settings' })).toBeDisabled();
await waitFor(() => expect(resolveUpdate).toBeTypeOf('function'));
expect(requestBody).toMatchObject({ reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 60 } });
resolveUpdate?.(HttpResponse.json({
success: true,
config: {
rateLimit: config.rateLimit,
browser: config.browser,
reddit: { ...config.reddit, prioritizeRecentPosts: true },
},
subreddits: config.subreddits,
}));
expect(await screen.findByText('Settings saved.')).toBeInTheDocument();
expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument();
await user.click(prioritize);
expect(screen.getByText('You have unsaved changes.')).toBeInTheDocument();
expect(screen.queryByText('Settings saved.')).not.toBeInTheDocument();
});
it('validates subreddit rows and confirms disabling dry run before saving behaviour', async () => {
installConfigApi();
let updateRequests = 0;
server.use(
http.put('/api/config', async ({ request }) => {
updateRequests += 1;
const body = await request.json() as { browser: { dryRun: boolean }; subreddits: typeof config.subreddits };
return HttpResponse.json({
success: true,
config: { rateLimit: config.rateLimit, browser: body.browser, reddit: config.reddit },
subreddits: body.subreddits,
});
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
await user.click(await screen.findByRole('button', { name: 'Add subreddit' }));
await user.type(screen.getByRole('textbox', { name: 'Subreddit 2 name' }), 'CasualConversation');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Subreddit names must be unique.')).toBeInTheDocument();
expect(updateRequests).toBe(0);
const secondName = screen.getByRole('textbox', { name: 'Subreddit 2 name' });
await user.clear(secondName);
await user.type(secondName, 'askreddit');
await user.click(screen.getByRole('checkbox', { name: 'Dry run mode' }));
await user.click(screen.getByRole('button', { name: 'Save settings' }));
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
expect(dialog).toHaveTextContent('Automated messages will be sent to Reddit');
expect(updateRequests).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
expect(await screen.findByText('Settings saved.')).toBeInTheDocument();
expect(updateRequests).toBe(1);
});
it('confirms unusually large hourly limits', async () => {
installConfigApi();
let updateRequests = 0;
server.use(http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({ success: true, config, subreddits: config.subreddits });
}));
const user = userEvent.setup();
renderSettings('/settings?section=rate-limits');
const hourlyLimit = await screen.findByRole('spinbutton', { name: 'Maximum messages per hour' });
await user.clear(hourlyLimit);
await user.type(hourlyLimit, '50');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByRole('dialog', { name: 'Confirm risky settings' })).toHaveTextContent('50 messages per hour');
expect(updateRequests).toBe(0);
});
it('shows Reddit session and noVNC state without exposing credentials, then starts and rechecks login', async () => {
let statusRequests = 0;
let loginRequests = 0;
server.use(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/login/info', ({ request }) => {
expect(request.credentials).toBe('same-origin');
return HttpResponse.json({
vncEnabled: true,
novncPort: 6080,
publicUrl: 'https://login.example/vnc.html',
passwordRequired: true,
});
}),
http.get('/api/login/status', () => {
statusRequests += 1;
return HttpResponse.json(statusRequests === 1
? { ready: true, loggedIn: false, status: 'login-required' }
: { ready: true, loggedIn: true, status: 'authenticated' });
}),
http.post('/api/login/start', ({ request }) => {
expect(request.credentials).toBe('same-origin');
loginRequests += 1;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=access');
expect(await screen.findByText('Login required')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open noVNC login window' })).toHaveAttribute('href', 'https://login.example/vnc.html');
expect(screen.getByText('A VNC password is required.')).toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Open Reddit login' }));
expect(await screen.findByText('Login page opened.')).toBeInTheDocument();
expect(loginRequests).toBe(1);
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Recheck session' }));
expect(statusRequests).toBeGreaterThanOrEqual(3);
});
});
+21
View File
@@ -22,4 +22,25 @@ describe('VNC container entrypoint', () => {
expect(script).toContain('"${BROWSER_SESSION_DIR}/SingletonCookie"'); expect(script).toContain('"${BROWSER_SESSION_DIR}/SingletonCookie"');
expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot')); expect(script.indexOf('SingletonLock')).toBeLessThan(script.indexOf('launching bot'));
}); });
it('launches regular Google Chrome before the bot and exposes its local CDP endpoint', () => {
const script = fs.readFileSync(path.resolve('docker/entrypoint.sh'), 'utf8');
expect(script).toContain('google-chrome');
expect(script).toContain('--user-data-dir="${BROWSER_SESSION_DIR}"');
expect(script).toContain('--remote-debugging-port="${CHROME_CDP_PORT}"');
expect(script).toContain('export CHROME_CDP_URL=');
expect(script).not.toContain('--enable-automation');
expect(script.indexOf('starting Google Chrome')).toBeLessThan(
script.indexOf('launching bot'),
);
});
it('installs the stable Google Chrome package rather than Chrome for Testing', () => {
const dockerfile = fs.readFileSync(path.resolve('Dockerfile'), 'utf8');
expect(dockerfile).toContain('google-chrome-stable_current_amd64.deb');
expect(dockerfile).toContain('google-chrome-stable');
expect(dockerfile).not.toContain('chrome-for-testing');
});
}); });