mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
Support saving reproductions
This commit is contained in:
parent
ae60b2a798
commit
3b890f27e3
4
.github/workflows/playwright.yaml
vendored
4
.github/workflows/playwright.yaml
vendored
@ -57,6 +57,7 @@ jobs:
|
|||||||
NUT_PASSWORD: ${{ secrets.NUT_PASSWORD }}
|
NUT_PASSWORD: ${{ secrets.NUT_PASSWORD }}
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
@ -64,6 +65,7 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Deploy playwright report to Vercel
|
- name: Deploy playwright report to Vercel
|
||||||
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
cd playwright-report
|
cd playwright-report
|
||||||
vercel link --project playwright-reports --yes --token=${{ secrets.VERCEL_TOKEN }}
|
vercel link --project playwright-reports --yes --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
@ -111,6 +113,7 @@ jobs:
|
|||||||
NUT_PASSWORD: ${{ secrets.NUT_PASSWORD }}
|
NUT_PASSWORD: ${{ secrets.NUT_PASSWORD }}
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report-supabase
|
name: playwright-report-supabase
|
||||||
@ -118,6 +121,7 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Deploy Supabase playwright report to Vercel
|
- name: Deploy Supabase playwright report to Vercel
|
||||||
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
cd playwright-report
|
cd playwright-report
|
||||||
vercel link --project playwright-reports --yes --token=${{ secrets.VERCEL_TOKEN }}
|
vercel link --project playwright-reports --yes --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
@ -14,45 +14,21 @@ interface LoadProblemButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setLastLoadedProblem(problem: BoltProblem) {
|
export function setLastLoadedProblem(problem: BoltProblem) {
|
||||||
const problemSerialized = JSON.stringify(problem);
|
localStorage.setItem('loadedProblemId', problem.problemId);
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem('loadedProblemId', problem.problemId);
|
|
||||||
localStorage.setItem('loadedProblem', problemSerialized);
|
|
||||||
} catch (error: any) {
|
|
||||||
// Remove loadedProblem, so we don't accidentally associate (e.g. reproduction) data with the wrong problem.
|
|
||||||
localStorage.removeItem('loadedProblem');
|
|
||||||
console.error(
|
|
||||||
`Failed to set last loaded problem (size=${(problemSerialized.length / 1024).toFixed(2)}kb):`,
|
|
||||||
error.stack || error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrFetchLastLoadedProblem(): Promise<BoltProblem | null> {
|
export async function getOrFetchLastLoadedProblem(): Promise<BoltProblem | null> {
|
||||||
const problemJSON = localStorage.getItem('loadedProblem');
|
|
||||||
let problem: BoltProblem | null = null;
|
let problem: BoltProblem | null = null;
|
||||||
|
const problemId = localStorage.getItem('loadedProblemId');
|
||||||
|
|
||||||
if (problemJSON) {
|
if (!problemId) {
|
||||||
problem = JSON.parse(problemJSON);
|
return null;
|
||||||
} else {
|
}
|
||||||
/*
|
|
||||||
* Problem might not have fit into localStorage.
|
|
||||||
* Try to re-load it from server.
|
|
||||||
*/
|
|
||||||
const problemId = localStorage.getItem('loadedProblemId');
|
|
||||||
|
|
||||||
if (!problemId) {
|
problem = await getProblem(problemId);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
problem = await getProblem(problemId);
|
if (!problem) {
|
||||||
|
return null;
|
||||||
if (!problem) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLastLoadedProblem(problem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return problem;
|
return problem;
|
||||||
|
@ -11,6 +11,7 @@ export default function ConnectionsTab() {
|
|||||||
const [loginKey, setLoginKey] = useState(getNutLoginKey() || '');
|
const [loginKey, setLoginKey] = useState(getNutLoginKey() || '');
|
||||||
const numFreeUses = +(Cookies.get(anthropicNumFreeUsesCookieName) || 0);
|
const numFreeUses = +(Cookies.get(anthropicNumFreeUsesCookieName) || 0);
|
||||||
|
|
||||||
|
const [loginKeyIsLoading, setLoginKeyIsLoading] = useState(false);
|
||||||
const handleSaveAPIKey = async (key: string) => {
|
const handleSaveAPIKey = async (key: string) => {
|
||||||
if (key && !key.startsWith('sk-ant-')) {
|
if (key && !key.startsWith('sk-ant-')) {
|
||||||
toast.error('Please provide a valid Anthropic API key');
|
toast.error('Please provide a valid Anthropic API key');
|
||||||
@ -37,10 +38,13 @@ export default function ConnectionsTab() {
|
|||||||
setLoginKey(key);
|
setLoginKey(key);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setLoginKeyIsLoading(true);
|
||||||
await saveNutLoginKey(key);
|
await saveNutLoginKey(key);
|
||||||
toast.success('Login key saved');
|
toast.success('Login key saved');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save login key');
|
toast.error('Failed to save login key');
|
||||||
|
} finally {
|
||||||
|
setLoginKeyIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,6 +87,8 @@ export default function ConnectionsTab() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter your login key"
|
placeholder="Enter your login key"
|
||||||
value={loginKey}
|
value={loginKey}
|
||||||
|
data-testid="login-key-input"
|
||||||
|
data-isloading={loginKeyIsLoading}
|
||||||
onChange={(e) => handleSaveLoginKey(e.target.value)}
|
onChange={(e) => handleSaveLoginKey(e.target.value)}
|
||||||
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50"
|
className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
|
@ -2,11 +2,13 @@ import { toast } from 'react-toastify';
|
|||||||
import ReactModal from 'react-modal';
|
import ReactModal from 'react-modal';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { workbenchStore } from '~/lib/stores/workbench';
|
import { workbenchStore } from '~/lib/stores/workbench';
|
||||||
import { getUsername, submitProblem, saveUsername, BoltProblemStatus } from '~/lib/replay/Problems';
|
import { submitProblem, BoltProblemStatus } from '~/lib/replay/Problems';
|
||||||
import type { BoltProblemInput } from '~/lib/replay/Problems';
|
import type { BoltProblemInput } from '~/lib/replay/Problems';
|
||||||
import { getRepositoryContents } from '~/lib/replay/Repository';
|
import { getRepositoryContents } from '~/lib/replay/Repository';
|
||||||
import { shouldUseSupabase, getCurrentUser, isAuthenticated } from '~/lib/supabase/client';
|
import { shouldUseSupabase, getCurrentUser } from '~/lib/supabase/client';
|
||||||
import { authModalStore } from '~/lib/stores/authModal';
|
import { authModalStore } from '~/lib/stores/authModal';
|
||||||
|
import { authStatusStore } from '~/lib/stores/auth';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
|
||||||
ReactModal.setAppElement('#root');
|
ReactModal.setAppElement('#root');
|
||||||
|
|
||||||
@ -20,27 +22,15 @@ export function SaveProblem() {
|
|||||||
username: '',
|
username: '',
|
||||||
});
|
});
|
||||||
const [problemId, setProblemId] = useState<string | null>(null);
|
const [problemId, setProblemId] = useState<string | null>(null);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
const isLoggedIn = useStore(authStatusStore.isLoggedIn);
|
||||||
|
const username = useStore(authStatusStore.username);
|
||||||
|
|
||||||
// Check authentication status and get username
|
// Update the username from the store when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function checkAuthAndUsername() {
|
if (username) {
|
||||||
if (shouldUseSupabase()) {
|
setFormData((prev) => ({ ...prev, username }));
|
||||||
const authenticated = await isAuthenticated();
|
|
||||||
setIsLoggedIn(authenticated);
|
|
||||||
} else {
|
|
||||||
setIsLoggedIn(true); // Always considered logged in when not using Supabase
|
|
||||||
|
|
||||||
const username = getUsername();
|
|
||||||
|
|
||||||
if (username) {
|
|
||||||
setFormData((prev) => ({ ...prev, username }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [username]);
|
||||||
checkAuthAndUsername();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSaveProblem = (e: React.MouseEvent) => {
|
const handleSaveProblem = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -55,6 +45,11 @@ export function SaveProblem() {
|
|||||||
...prev,
|
...prev,
|
||||||
[name]: value,
|
[name]: value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Update username in the store if it's the username field
|
||||||
|
if (name === 'username') {
|
||||||
|
authStatusStore.updateUsername(value);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitProblem = async () => {
|
const handleSubmitProblem = async () => {
|
||||||
@ -68,11 +63,6 @@ export function SaveProblem() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only save username to cookie if not using Supabase
|
|
||||||
if (!shouldUseSupabase()) {
|
|
||||||
saveUsername(formData.username);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.info('Submitting problem...');
|
toast.info('Submitting problem...');
|
||||||
|
|
||||||
const repositoryId = workbenchStore.repositoryId.get();
|
const repositoryId = workbenchStore.repositoryId.get();
|
||||||
@ -98,6 +88,7 @@ export function SaveProblem() {
|
|||||||
|
|
||||||
if (problemId) {
|
if (problemId) {
|
||||||
setProblemId(problemId);
|
setProblemId(problemId);
|
||||||
|
localStorage.setItem('loadedProblemId', problemId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -16,7 +16,15 @@ import {
|
|||||||
import { getNutIsAdmin as getNutIsAdminFromSupabase } from '~/lib/supabase/client';
|
import { getNutIsAdmin as getNutIsAdminFromSupabase } from '~/lib/supabase/client';
|
||||||
import { updateIsAdmin, updateUsername } from '~/lib/stores/user';
|
import { updateIsAdmin, updateUsername } from '~/lib/stores/user';
|
||||||
|
|
||||||
|
// Add global declaration for the problem property
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__currentProblem__?: BoltProblem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface BoltProblemComment {
|
export interface BoltProblemComment {
|
||||||
|
id?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@ -99,50 +107,57 @@ export async function listAllProblems(): Promise<BoltProblemDescription[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getProblem(problemId: string): Promise<BoltProblem | null> {
|
export async function getProblem(problemId: string): Promise<BoltProblem | null> {
|
||||||
|
let problem: BoltProblem | null = null;
|
||||||
|
|
||||||
if (shouldUseSupabase()) {
|
if (shouldUseSupabase()) {
|
||||||
return supabaseGetProblem(problemId);
|
problem = await supabaseGetProblem(problemId);
|
||||||
}
|
} else {
|
||||||
|
try {
|
||||||
|
if (!problemId) {
|
||||||
|
toast.error('Invalid problem ID');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const rv = await sendCommandDedicatedClient({
|
||||||
if (!problemId) {
|
method: 'Recording.globalExperimentalCommand',
|
||||||
toast.error('Invalid problem ID');
|
params: {
|
||||||
return null;
|
name: 'fetchBoltProblem',
|
||||||
}
|
params: { problemId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const rv = await sendCommandDedicatedClient({
|
problem = (rv as { rval: { problem: BoltProblem } }).rval.problem;
|
||||||
method: 'Recording.globalExperimentalCommand',
|
|
||||||
params: {
|
|
||||||
name: 'fetchBoltProblem',
|
|
||||||
params: { problemId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const problem = (rv as { rval: { problem: BoltProblem } }).rval.problem;
|
if (!problem) {
|
||||||
|
toast.error('Problem not found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!problem) {
|
if ('prompt' in problem) {
|
||||||
toast.error('Problem not found');
|
// 2/11/2025: Update obsolete data format for older problems.
|
||||||
return null;
|
problem.repositoryContents = (problem as any).prompt.content;
|
||||||
}
|
delete problem.prompt;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching problem', error);
|
||||||
|
|
||||||
if ('prompt' in problem) {
|
// Check for specific protocol error
|
||||||
// 2/11/2025: Update obsolete data format for older problems.
|
if (error instanceof Error && error.message.includes('Unknown problem ID')) {
|
||||||
problem.repositoryContents = (problem as any).prompt.content;
|
toast.error('Problem not found');
|
||||||
delete problem.prompt;
|
} else {
|
||||||
}
|
toast.error('Failed to fetch problem');
|
||||||
|
}
|
||||||
return problem;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching problem', error);
|
|
||||||
|
|
||||||
// Check for specific protocol error
|
|
||||||
if (error instanceof Error && error.message.includes('Unknown problem ID')) {
|
|
||||||
toast.error('Problem not found');
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to fetch problem');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
/*
|
||||||
|
* Only used for testing
|
||||||
|
*/
|
||||||
|
if (problem) {
|
||||||
|
window.__currentProblem__ = problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return problem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitProblem(problem: BoltProblemInput): Promise<string | null> {
|
export async function submitProblem(problem: BoltProblemInput): Promise<string | null> {
|
||||||
@ -177,29 +192,33 @@ export async function deleteProblem(problemId: string): Promise<void | undefined
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nutLoginKeyCookieName = 'nutLoginKey';
|
||||||
|
const nutIsAdminCookieName = 'nutIsAdmin';
|
||||||
|
const nutUsernameCookieName = 'nutUsername';
|
||||||
|
|
||||||
export async function updateProblem(problemId: string, problem: BoltProblemInput): Promise<BoltProblem | null> {
|
export async function updateProblem(problemId: string, problem: BoltProblemInput): Promise<BoltProblem | null> {
|
||||||
if (shouldUseSupabase()) {
|
if (shouldUseSupabase()) {
|
||||||
await supabaseUpdateProblem(problemId, problem);
|
await supabaseUpdateProblem(problemId, problem);
|
||||||
}
|
} else {
|
||||||
|
try {
|
||||||
|
if (!getNutIsAdmin()) {
|
||||||
|
toast.error('Admin user required');
|
||||||
|
|
||||||
try {
|
return null;
|
||||||
if (!getNutIsAdmin()) {
|
}
|
||||||
toast.error('Admin user required');
|
|
||||||
|
|
||||||
return null;
|
const loginKey = Cookies.get(nutLoginKeyCookieName);
|
||||||
|
await sendCommandDedicatedClient({
|
||||||
|
method: 'Recording.globalExperimentalCommand',
|
||||||
|
params: {
|
||||||
|
name: 'updateBoltProblem',
|
||||||
|
params: { problemId, problem, loginKey },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating problem', error);
|
||||||
|
toast.error('Failed to update problem');
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginKey = Cookies.get(nutLoginKeyCookieName);
|
|
||||||
await sendCommandDedicatedClient({
|
|
||||||
method: 'Recording.globalExperimentalCommand',
|
|
||||||
params: {
|
|
||||||
name: 'updateBoltProblem',
|
|
||||||
params: { problemId, problem, loginKey },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating problem', error);
|
|
||||||
toast.error('Failed to update problem');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedProblem = await getProblem(problemId);
|
const updatedProblem = await getProblem(problemId);
|
||||||
@ -207,10 +226,6 @@ export async function updateProblem(problemId: string, problem: BoltProblemInput
|
|||||||
return updatedProblem;
|
return updatedProblem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nutLoginKeyCookieName = 'nutLoginKey';
|
|
||||||
const nutIsAdminCookieName = 'nutIsAdmin';
|
|
||||||
const nutUsernameCookieName = 'nutUsername';
|
|
||||||
|
|
||||||
export function getNutLoginKey(): string | undefined {
|
export function getNutLoginKey(): string | undefined {
|
||||||
const cookieValue = Cookies.get(nutLoginKeyCookieName);
|
const cookieValue = Cookies.get(nutLoginKeyCookieName);
|
||||||
return cookieValue?.length ? cookieValue : undefined;
|
return cookieValue?.length ? cookieValue : undefined;
|
||||||
|
@ -2,11 +2,58 @@ import { atom } from 'nanostores';
|
|||||||
import { getSupabase } from '~/lib/supabase/client';
|
import { getSupabase } from '~/lib/supabase/client';
|
||||||
import type { User, Session } from '@supabase/supabase-js';
|
import type { User, Session } from '@supabase/supabase-js';
|
||||||
import { logStore } from './logs';
|
import { logStore } from './logs';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { shouldUseSupabase, isAuthenticated } from '~/lib/supabase/client';
|
||||||
|
import { getUsername, saveUsername } from '~/lib/replay/Problems';
|
||||||
|
|
||||||
export const userStore = atom<User | null>(null);
|
export const userStore = atom<User | null>(null);
|
||||||
export const sessionStore = atom<Session | null>(null);
|
export const sessionStore = atom<Session | null>(null);
|
||||||
export const isLoadingStore = atom<boolean>(true);
|
export const isLoadingStore = atom<boolean>(true);
|
||||||
|
|
||||||
|
// Auth status store for both Supabase and non-Supabase modes
|
||||||
|
export const authStatusStore = {
|
||||||
|
isLoggedIn: atom<boolean | null>(null),
|
||||||
|
username: atom<string>(''),
|
||||||
|
|
||||||
|
// Initialize auth status store
|
||||||
|
async init() {
|
||||||
|
if (shouldUseSupabase()) {
|
||||||
|
// For Supabase, subscribe to the userStore
|
||||||
|
userStore.listen((user) => {
|
||||||
|
this.isLoggedIn.set(!!user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check initial auth state
|
||||||
|
const authenticated = await isAuthenticated();
|
||||||
|
this.isLoggedIn.set(authenticated);
|
||||||
|
} else {
|
||||||
|
// For non-Supabase, always logged in
|
||||||
|
this.isLoggedIn.set(true);
|
||||||
|
|
||||||
|
// Get username from storage
|
||||||
|
const storedUsername = getUsername();
|
||||||
|
|
||||||
|
if (storedUsername) {
|
||||||
|
this.username.set(storedUsername);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update username (only meaningful in non-Supabase mode)
|
||||||
|
updateUsername(newUsername: string) {
|
||||||
|
this.username.set(newUsername);
|
||||||
|
|
||||||
|
if (!shouldUseSupabase()) {
|
||||||
|
saveUsername(newUsername);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize auth status store
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
authStatusStore.init();
|
||||||
|
}
|
||||||
|
|
||||||
export async function initializeAuth() {
|
export async function initializeAuth() {
|
||||||
try {
|
try {
|
||||||
isLoadingStore.set(true);
|
isLoadingStore.set(true);
|
||||||
@ -143,3 +190,25 @@ export async function signOut() {
|
|||||||
isLoadingStore.set(false);
|
isLoadingStore.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the hook for backwards compatibility, but implement it using the store
|
||||||
|
export function useAuthStatus() {
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(authStatusStore.isLoggedIn.get());
|
||||||
|
const [username, setUsername] = useState<string>(authStatusStore.username.get());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribeIsLoggedIn = authStatusStore.isLoggedIn.listen(setIsLoggedIn);
|
||||||
|
const unsubscribeUsername = authStatusStore.username.listen(setUsername);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeIsLoggedIn();
|
||||||
|
unsubscribeUsername();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateUsername = (newUsername: string) => {
|
||||||
|
authStatusStore.updateUsername(newUsername);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isLoggedIn, username, updateUsername };
|
||||||
|
}
|
||||||
|
@ -18,8 +18,15 @@ export interface Database {
|
|||||||
repository_contents: Json;
|
repository_contents: Json;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
problem_comments: Database['public']['Tables']['problem_comments']['Row'][];
|
problem_comments: Database['public']['Tables']['problem_comments']['Row'][];
|
||||||
|
solution: Json;
|
||||||
|
repository_contents_path: string | null;
|
||||||
|
prompt_path: string | null;
|
||||||
|
solution_path: string | null;
|
||||||
};
|
};
|
||||||
Insert: Omit<Database['public']['Tables']['problems']['Row'], 'created_at' | 'updated_at' | 'problem_comments'>;
|
Insert: Omit<
|
||||||
|
Database['public']['Tables']['problems']['Row'],
|
||||||
|
'created_at' | 'updated_at' | 'problem_comments' | 'solution'
|
||||||
|
>;
|
||||||
Update: Partial<Database['public']['Tables']['problems']['Insert']>;
|
Update: Partial<Database['public']['Tables']['problems']['Insert']>;
|
||||||
};
|
};
|
||||||
problem_comments: {
|
problem_comments: {
|
||||||
|
@ -5,6 +5,18 @@ import { getSupabase, type Database } from './client';
|
|||||||
import type { BoltProblem, BoltProblemDescription, BoltProblemInput, BoltProblemStatus } from '~/lib/replay/Problems';
|
import type { BoltProblem, BoltProblemDescription, BoltProblemInput, BoltProblemStatus } from '~/lib/replay/Problems';
|
||||||
import { getUsername, getNutIsAdmin } from '~/lib/replay/Problems';
|
import { getUsername, getNutIsAdmin } from '~/lib/replay/Problems';
|
||||||
|
|
||||||
|
async function downloadBlob(bucket: string, path: string) {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
const { data, error } = await supabase.storage.from(bucket).download(path);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error downloading blob:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.text();
|
||||||
|
}
|
||||||
|
|
||||||
export async function supabaseListAllProblems(): Promise<BoltProblemDescription[]> {
|
export async function supabaseListAllProblems(): Promise<BoltProblemDescription[]> {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await getSupabase()
|
const { data, error } = await getSupabase()
|
||||||
@ -72,61 +84,20 @@ export async function supabaseGetProblem(problemId: string): Promise<BoltProblem
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch blob data from storage if paths are available
|
// Fetch blob data from storage if paths are available
|
||||||
let repositoryContents = '';
|
let repositoryContents = data.repository_contents;
|
||||||
let prompt = undefined;
|
let solution = data.solution;
|
||||||
let solution = undefined;
|
const prompt = data.prompt;
|
||||||
|
|
||||||
// Create a supabase instance for storage operations
|
// Create a supabase instance for storage operations
|
||||||
const supabase = getSupabase();
|
const supabase = getSupabase();
|
||||||
|
|
||||||
// Fetch repository contents from storage if path is available
|
// Fetch repository contents from storage if path is available
|
||||||
if (data.repository_contents_path) {
|
if (data.repository_contents_path) {
|
||||||
try {
|
repositoryContents = (await downloadBlob('repository-contents', data.repository_contents_path)) || '';
|
||||||
const { data: blobData, error: blobError } = await supabase.storage
|
|
||||||
.from('repository-contents')
|
|
||||||
.download(data.repository_contents_path);
|
|
||||||
|
|
||||||
if (!blobError && blobData) {
|
|
||||||
repositoryContents = await blobData.text();
|
|
||||||
} else {
|
|
||||||
console.error('Error fetching repository contents:', blobError);
|
|
||||||
}
|
|
||||||
} catch (storageError) {
|
|
||||||
console.error('Error downloading repository contents:', storageError);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch repository contents from storage if path is available
|
|
||||||
if (data.prompt_path) {
|
|
||||||
try {
|
|
||||||
const { data: blobData, error: blobError } = await supabase.storage.from('prompts').download(data.prompt_path);
|
|
||||||
|
|
||||||
if (!blobError && blobData) {
|
|
||||||
prompt = await blobData.text();
|
|
||||||
} else {
|
|
||||||
console.error('Error fetching repository contents:', blobError);
|
|
||||||
}
|
|
||||||
} catch (storageError) {
|
|
||||||
console.error('Error downloading repository contents:', storageError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch solution from storage if path is available
|
|
||||||
if (data.solution_path) {
|
if (data.solution_path) {
|
||||||
try {
|
solution = JSON.parse((await downloadBlob('solutions', data.solution_path)) || '{}');
|
||||||
const { data: blobData, error: blobError } = await supabase.storage
|
|
||||||
.from('solutions')
|
|
||||||
.download(data.solution_path);
|
|
||||||
|
|
||||||
if (!blobError && blobData) {
|
|
||||||
const solutionText = await blobData.text();
|
|
||||||
solution = JSON.parse(solutionText);
|
|
||||||
} else {
|
|
||||||
console.error('Error fetching solution:', blobError);
|
|
||||||
}
|
|
||||||
} catch (storageError) {
|
|
||||||
console.error('Error downloading solution:', storageError);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the problem has a user_id, fetch the profile information
|
// If the problem has a user_id, fetch the profile information
|
||||||
@ -225,17 +196,14 @@ export async function supabaseUpdateProblem(problemId: string, problem: BoltProb
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract comments to add separately if needed
|
|
||||||
const comments = problem.comments || [];
|
|
||||||
delete (problem as any).comments;
|
|
||||||
|
|
||||||
// Convert to Supabase format
|
// Convert to Supabase format
|
||||||
const updates: Database['public']['Tables']['problems']['Update'] = {
|
const updates: Database['public']['Tables']['problems']['Update'] = {
|
||||||
title: problem.title,
|
title: problem.title,
|
||||||
description: problem.description,
|
description: problem.description,
|
||||||
status: problem.status,
|
status: problem.status,
|
||||||
keywords: problem.keywords || [],
|
keywords: problem.keywords || [],
|
||||||
repository_contents: problem.repositoryContents,
|
repository_contents_path: problem.repositoryContents ? `problems/${problemId}.txt` : undefined,
|
||||||
|
solution_path: problem.solution ? `solutions/${problemId}.json` : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the problem
|
// Update the problem
|
||||||
@ -245,38 +213,44 @@ export async function supabaseUpdateProblem(problemId: string, problem: BoltProb
|
|||||||
throw updateError;
|
throw updateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updates.repository_contents_path) {
|
||||||
|
const { error: repositoryContentsError } = await getSupabase()
|
||||||
|
.storage.from('repository-contents')
|
||||||
|
.upload(updates.repository_contents_path, problem.repositoryContents);
|
||||||
|
|
||||||
|
// @ts-ignore - ignore duplicate error
|
||||||
|
if (repositoryContentsError && repositoryContentsError.error !== 'Duplicate') {
|
||||||
|
throw repositoryContentsError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.solution_path) {
|
||||||
|
const { error: solutionError } = await getSupabase()
|
||||||
|
.storage.from('solutions')
|
||||||
|
.upload(updates.solution_path, JSON.stringify(problem.solution), { upsert: true });
|
||||||
|
|
||||||
|
if (solutionError) {
|
||||||
|
throw solutionError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle comments if they exist
|
// Handle comments if they exist
|
||||||
if (comments.length > 0) {
|
if (problem.comments && problem.comments.length > 0) {
|
||||||
/**
|
const commentInserts = problem.comments
|
||||||
* Create a unique identifier for each comment based on content and timestamp.
|
.filter((comment) => !comment.id)
|
||||||
* This allows us to use upsert with onConflict to avoid duplicates.
|
.map((comment) => {
|
||||||
*/
|
return {
|
||||||
const commentInserts = comments.map((comment) => {
|
problem_id: problemId,
|
||||||
// Ensure timestamp is a valid number
|
content: comment.content,
|
||||||
const timestamp =
|
username: comment.username || getUsername() || 'Anonymous',
|
||||||
typeof comment.timestamp === 'number' && !isNaN(comment.timestamp) ? comment.timestamp : Date.now();
|
};
|
||||||
|
});
|
||||||
return {
|
|
||||||
problem_id: problemId,
|
|
||||||
content: comment.content,
|
|
||||||
username: comment.username || getUsername() || 'Anonymous',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use timestamp as a unique identifier for the comment.
|
|
||||||
* This assumes that comments with the same timestamp are the same comment.
|
|
||||||
*/
|
|
||||||
created_at: new Date(timestamp).toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use upsert with onConflict to avoid duplicates.
|
* Use upsert with onConflict to avoid duplicates.
|
||||||
* This will insert new comments and ignore existing ones based on created_at.
|
* This will insert new comments and ignore existing ones based on created_at.
|
||||||
*/
|
*/
|
||||||
const { error: commentsError } = await getSupabase().from('problem_comments').upsert(commentInserts, {
|
const { error: commentsError } = await getSupabase().from('problem_comments').insert(commentInserts);
|
||||||
onConflict: 'created_at',
|
|
||||||
ignoreDuplicates: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (commentsError) {
|
if (commentsError) {
|
||||||
throw commentsError;
|
throw commentsError;
|
||||||
|
@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 1,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
timeout: 60000, // Increase global timeout to 60 seconds
|
timeout: 60000, // Increase global timeout to 60 seconds
|
||||||
use: {
|
use: {
|
||||||
|
@ -24,43 +24,27 @@ test('Should be able to save a problem ', async ({ page }) => {
|
|||||||
await page.getByRole('link', { name: 'App goes blank getting' }).click();
|
await page.getByRole('link', { name: 'App goes blank getting' }).click();
|
||||||
await page.getByRole('link', { name: 'Load Problem' }).click();
|
await page.getByRole('link', { name: 'Load Problem' }).click();
|
||||||
|
|
||||||
|
const useSupabase = await isSupabaseEnabled(page);
|
||||||
|
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
|
||||||
|
await login(page);
|
||||||
|
|
||||||
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
|
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
const useSupabase = await isSupabaseEnabled(page);
|
await openSidebar(page);
|
||||||
|
await page.getByRole('button', { name: 'Save Problem' }).click();
|
||||||
|
|
||||||
if (useSupabase) {
|
await page.locator('input[name="title"]').click();
|
||||||
await openSidebar(page);
|
await page.locator('input[name="title"]').fill('[test] playwright');
|
||||||
await page.getByRole('button', { name: 'Save Problem' }).click();
|
await page.locator('input[name="description"]').click();
|
||||||
await page.getByRole('button', { name: 'Log In' }).click();
|
await page.locator('input[name="description"]').fill('...');
|
||||||
await page.getByRole('textbox', { name: 'Email' }).click();
|
|
||||||
|
|
||||||
await login(page);
|
if (!useSupabase) {
|
||||||
|
|
||||||
await expect(page.getByText('Import the "problem" folder')).toBeVisible({ timeout: 30000 });
|
|
||||||
|
|
||||||
await page.locator('[data-testid="sidebar-icon"]').click();
|
|
||||||
await page.getByRole('button', { name: 'Save Problem' }).click();
|
|
||||||
|
|
||||||
await page.locator('input[name="title"]').click();
|
|
||||||
await page.locator('input[name="title"]').fill('[test] playwright');
|
|
||||||
await page.locator('input[name="description"]').click();
|
|
||||||
await page.locator('input[name="description"]').fill('...');
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
|
||||||
} else {
|
|
||||||
await page.locator('[data-testid="sidebar-icon"]').click();
|
|
||||||
await page.getByRole('button', { name: 'Save Problem' }).click();
|
|
||||||
|
|
||||||
await page.locator('input[name="title"]').click();
|
|
||||||
await page.locator('input[name="title"]').fill('[test] playwright');
|
|
||||||
await page.locator('input[name="description"]').click();
|
|
||||||
await page.locator('input[name="description"]').fill('...');
|
|
||||||
await page.locator('input[name="username"]').click();
|
await page.locator('input[name="username"]').click();
|
||||||
await page.locator('input[name="username"]').fill('playwright');
|
await page.locator('input[name="username"]').fill('playwright');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Close' }).click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Should be able to update a problem', async ({ page }) => {
|
test('Should be able to update a problem', async ({ page }) => {
|
||||||
@ -70,11 +54,7 @@ test('Should be able to update a problem', async ({ page }) => {
|
|||||||
await page.getByRole('link', { name: '[test] playwright' }).first().click();
|
await page.getByRole('link', { name: '[test] playwright' }).first().click();
|
||||||
expect(await page.getByRole('textbox', { name: 'Set the title of the problem' })).not.toBeVisible();
|
expect(await page.getByRole('textbox', { name: 'Set the title of the problem' })).not.toBeVisible();
|
||||||
|
|
||||||
if (await isSupabaseEnabled(page)) {
|
await login(page);
|
||||||
await login(page);
|
|
||||||
} else {
|
|
||||||
await setLoginKey(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
const hours = currentTime.getHours().toString().padStart(2, '0');
|
const hours = currentTime.getHours().toString().padStart(2, '0');
|
||||||
@ -117,11 +97,7 @@ test('Should be able to add a comment to a problem', async ({ page }) => {
|
|||||||
await page.getByRole('combobox').selectOption('all');
|
await page.getByRole('combobox').selectOption('all');
|
||||||
await page.getByRole('link', { name: '[test] playwright' }).first().click();
|
await page.getByRole('link', { name: '[test] playwright' }).first().click();
|
||||||
|
|
||||||
if (await isSupabaseEnabled(page)) {
|
await login(page);
|
||||||
await login(page);
|
|
||||||
} else {
|
|
||||||
await setLoginKey(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a comment to the problem
|
// Add a comment to the problem
|
||||||
const comment = `test comment ${Date.now().toString()}`;
|
const comment = `test comment ${Date.now().toString()}`;
|
||||||
@ -138,12 +114,50 @@ test('Should be able to add a comment to a problem', async ({ page }) => {
|
|||||||
test('Confirm that admins see the "Save Reproduction" button', async ({ page }) => {
|
test('Confirm that admins see the "Save Reproduction" button', async ({ page }) => {
|
||||||
await page.goto('/problems?showAll=true');
|
await page.goto('/problems?showAll=true');
|
||||||
|
|
||||||
if (await isSupabaseEnabled(page)) {
|
await login(page);
|
||||||
await login(page);
|
|
||||||
} else {
|
|
||||||
await setLoginKey(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
await openSidebar(page);
|
await openSidebar(page);
|
||||||
await expect(page.getByRole('link', { name: 'Save Reproduction' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Save Reproduction' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Should be able to save a reproduction', async ({ page }) => {
|
||||||
|
await page.goto('/problems?showAll=true');
|
||||||
|
await page.getByRole('combobox').selectOption('all');
|
||||||
|
await page.getByRole('link', { name: '[test] tic tac toe' }).first().click();
|
||||||
|
|
||||||
|
const shouldUseSupabase = await isSupabaseEnabled(page);
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Load Problem' }).click();
|
||||||
|
|
||||||
|
// TODO: Find a way to interact with the tic tac toe board
|
||||||
|
// find the cell in the tic tac toe board inside the iframe
|
||||||
|
// const frameLocator = page.frameLocator('iframe[title="preview"]').first();
|
||||||
|
// await frameLocator.getByTestId('cell-0-0').click();
|
||||||
|
|
||||||
|
const message = `test message ${Date.now().toString()}`;
|
||||||
|
|
||||||
|
await page.getByRole('textbox', { name: 'How can we help you?' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'How can we help you?' }).fill(message);
|
||||||
|
await page.getByRole('button', { name: 'Chat', exact: true }).click();
|
||||||
|
|
||||||
|
await openSidebar(page);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Save Reproduction' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
await expect(page.getByText('Reproduction saved')).toBeVisible();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check to see if __currentProblem__ is set and has the correct solution message
|
||||||
|
*/
|
||||||
|
const currentProblem = await page.evaluate(() => {
|
||||||
|
// @ts-ignore - accessing window.__currentProblem__ which is defined at runtime
|
||||||
|
return window.__currentProblem__;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only supabase is working for now
|
||||||
|
if (shouldUseSupabase) {
|
||||||
|
// Check if the message is a text message before accessing content
|
||||||
|
const message3 = currentProblem?.solution?.messages[2];
|
||||||
|
expect(message3 && message3.type === 'text' ? (message3 as any).content : null).toBe(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
@ -57,8 +57,15 @@ export async function getElementText(page: Page, selector: string): Promise<stri
|
|||||||
export async function openSidebar(page: Page): Promise<void> {
|
export async function openSidebar(page: Page): Promise<void> {
|
||||||
await page.locator('[data-testid="sidebar-icon"]').click();
|
await page.locator('[data-testid="sidebar-icon"]').click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(page: Page): Promise<void> {
|
export async function login(page: Page): Promise<void> {
|
||||||
|
if (await isSupabaseEnabled(page)) {
|
||||||
|
await loginToSupabase(page);
|
||||||
|
} else {
|
||||||
|
await setLoginKey(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginToSupabase(page: Page): Promise<void> {
|
||||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).click();
|
await page.getByRole('textbox', { name: 'Email' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Email' }).fill(process.env.SUPABASE_TEST_USER_EMAIL || '');
|
await page.getByRole('textbox', { name: 'Email' }).fill(process.env.SUPABASE_TEST_USER_EMAIL || '');
|
||||||
@ -77,6 +84,9 @@ export async function setLoginKey(page: Page): Promise<void> {
|
|||||||
await page.getByRole('textbox', { name: 'Enter your login key' }).click();
|
await page.getByRole('textbox', { name: 'Enter your login key' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Enter your login key' }).fill(process.env.NUT_LOGIN_KEY || '');
|
await page.getByRole('textbox', { name: 'Enter your login key' }).fill(process.env.NUT_LOGIN_KEY || '');
|
||||||
|
|
||||||
|
// wait for loading to finish data-isloading="true"
|
||||||
|
await page.waitForSelector('input[data-isloading="false"]', { state: 'attached' });
|
||||||
|
|
||||||
await page.getByRole('textbox', { name: 'Enter your username' }).click();
|
await page.getByRole('textbox', { name: 'Enter your username' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Enter your username' }).fill(process.env.NUT_USERNAME || '');
|
await page.getByRole('textbox', { name: 'Enter your username' }).fill(process.env.NUT_USERNAME || '');
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user