This commit is contained in:
seb
2026-10-07 21:28:26 +02:00
parent ee5a30bb21
commit d49ec77043
509 changed files with 2969 additions and 924 deletions

5
.gitignore vendored
View File

@@ -4,4 +4,7 @@ dist-ssr
*.local
.DS_Store
certs/
.dashfront-token
.dashfront-token
# Visual testing rig: generated screenshots (reference is committed, current/diff are not)
test/screenshots/current/
test/screenshots/diff/

View File

@@ -12,7 +12,9 @@
"build": "vite build",
"preview": "vite preview",
"preview:https": "VITE_HTTPS=1 vite preview",
"lint": "eslint ."
"lint": "eslint .",
"rig:reference": "node test/collect.mjs && node test/capture.mjs --out=test/screenshots/reference",
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001"
},
"dependencies": {
"@emotion/react": "^11.14.0",

View File

@@ -680,7 +680,7 @@ export const setting = {
};
/**
* Content-addressed thumb AVIF by fullHash (SHA-256 hex of full image bytes).
* Content-addressed thumb AVIF by fullHash (8-char alphanumeric, see dashback pic.js).
* Auth: session cookie. Browser-cacheable (private, immutable, ETag).
*/
export const thumb = {
@@ -2021,6 +2021,34 @@ export const vfs = {
}
return res.json();
},
/**
* POST /api/vfs/mkdir
* Create a directory in VFS.
* @param {string} path
*/
mkdir(path) {
const clean = String(path || '').replace(/^\/+/, '');
return http('/api/vfs/mkdir', {
method: 'POST',
body: { path: clean },
});
},
/**
* POST /api/vfs/rename
* Rename or move a file or directory in VFS.
* @param {string} oldPath
* @param {string} newPath
*/
rename(oldPath, newPath) {
const cleanOld = String(oldPath || '').replace(/^\/+/, '');
const cleanNew = String(newPath || '').replace(/^\/+/, '');
return http('/api/vfs/rename', {
method: 'POST',
body: { oldPath: cleanOld, newPath: cleanNew },
});
},
};
export default {

View File

@@ -67,6 +67,7 @@ const initialState = {
searchQuery: '',
vfsWidth: 480,
newFileDialogOpen: false,
newFileDefaultFolder: '',
};
let vfsAutomat;
@@ -105,8 +106,8 @@ const actions = {
vfsAutomat.setState({ searchQuery });
},
setNewFileDialogOpen(newFileDialogOpen) {
vfsAutomat.setState({ newFileDialogOpen });
setNewFileDialogOpen(newFileDialogOpen, newFileDefaultFolder = '') {
vfsAutomat.setState({ newFileDialogOpen, newFileDefaultFolder });
},
/**
@@ -283,6 +284,54 @@ const actions = {
}
},
/**
* Delete a file or directory from VFS.
* @param {string} path
*/
async deletePath(path) {
return actions.deleteFile(path);
},
/**
* Create a new directory in VFS.
* @param {string} path
*/
async mkdir(path) {
const clean = String(path || '').trim().replace(/^[/\\]+/, '');
if (!clean) throw new Error('Pfad darf nicht leer sein');
try {
const res = await vfsApi.mkdir(clean);
await actions.loadFiles(true);
return res;
} catch (err) {
console.error('[vfsAutomat] mkdir failed:', err);
throw err;
}
},
/**
* Rename or move a file or directory in VFS.
* @param {string} oldPath
* @param {string} newPath
*/
async renameFile(oldPath, newPath) {
const cleanOld = String(oldPath || '').trim().replace(/^[/\\]+/, '');
const cleanNew = String(newPath || '').trim().replace(/^[/\\]+/, '');
if (!cleanOld || !cleanNew) throw new Error('Alter und neuer Pfad dürfen nicht leer sein');
try {
const res = await vfsApi.rename(cleanOld, cleanNew);
const isCurrentSelected = vfsAutomat.state.selectedFileName === cleanOld;
await actions.loadFiles(true);
if (isCurrentSelected) {
await actions.selectFile(cleanNew);
}
return res;
} catch (err) {
console.error('[vfsAutomat] renameFile failed:', err);
throw err;
}
},
/**
* Upload one or more files to VFS.
* @param {File[]|FileList|File} files

View File

@@ -26,6 +26,9 @@ import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded';
import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded';
import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded';
import { setting as settingApi } from '../../api';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
@@ -627,6 +630,17 @@ export default class ChatDrawer extends PureComponent {
this.setState({ modelreasoning: current });
}
};
handleSetModelslug = async (nextSlug) => {
if (this.state.modelslug === nextSlug) return;
const current = this.state.modelslug;
this.setState({ modelslug: nextSlug });
try {
await settingApi.upsert('modelslug', { value: nextSlug });
} catch (err) {
console.error('[ChatDrawer] Failed to save modelslug:', err);
this.setState({ modelslug: current });
}
};
handleModelslugChange = (e) => {
this.setState({ modelslug: e.target.value });
@@ -871,6 +885,79 @@ export default class ChatDrawer extends PureComponent {
<Divider orientation="vertical" flexItem sx={{ mx: 0.3, my: 0.5 }} />
{/* Group 3: Model preset shortcuts (moon -> luna, sun -> sol, universe -> astra) */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.3,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'),
p: 0.3,
borderRadius: 2,
flexShrink: 0,
}}
>
{/* moon toggle (openai/gpt-6-luna) */}
<IconButton
size="small"
onClick={() => this.handleSetModelslug('openai/gpt-6-luna')}
aria-label="Luna (openai/gpt-6-luna)"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelslug === 'openai/gpt-6-luna' ? 'primary.main' : 'transparent',
color: modelslug === 'openai/gpt-6-luna' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelslug === 'openai/gpt-6-luna' ? 'primary.dark' : 'action.selected',
},
}}
>
<DarkModeRoundedIcon sx={{ fontSize: 16 }} />
</IconButton>
{/* sun toggle (openai/gpt-6.1-sol) */}
<IconButton
size="small"
onClick={() => this.handleSetModelslug('openai/gpt-6.1-sol')}
aria-label="Sol (openai/gpt-6.1-sol)"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelslug === 'openai/gpt-6.1-sol' ? 'primary.main' : 'transparent',
color: modelslug === 'openai/gpt-6.1-sol' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelslug === 'openai/gpt-6.1-sol' ? 'primary.dark' : 'action.selected',
},
}}
>
<LightModeRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
{/* universe toggle (openai/gpt-6-astra) */}
<IconButton
size="small"
onClick={() => this.handleSetModelslug('openai/gpt-6-astra')}
aria-label="Astra (openai/gpt-6-astra)"
sx={{
p: 0.3,
width: 26,
height: 26,
borderRadius: 1.5,
bgcolor: modelslug === 'openai/gpt-6-astra' ? 'primary.main' : 'transparent',
color: modelslug === 'openai/gpt-6-astra' ? 'primary.contrastText' : 'text.secondary',
'&:hover': {
bgcolor: modelslug === 'openai/gpt-6-astra' ? 'primary.dark' : 'action.selected',
},
}}
>
<CycloneRoundedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
{/* modelslug input */}
<InputBase
value={modelslug || ''}
@@ -1167,6 +1254,8 @@ export default class ChatDrawer extends PureComponent {
<Box
component="form"
onSubmit={this.handleSend}
noValidate
autoComplete="off"
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onDragLeave={this.handleDragLeave}
@@ -1444,6 +1533,12 @@ export default class ChatDrawer extends PureComponent {
onFocus={() => this.setState({ isInputFocused: true })}
onBlur={() => this.setState({ isInputFocused: false })}
disabled={!canChat}
inputProps={{
spellCheck: false,
autoCorrect: 'off',
autoCapitalize: 'off',
autoComplete: 'off',
}}
placeholder={
pendingAsk
? 'Bitte Frage oben beantworten…'

View File

@@ -30,6 +30,7 @@ export default class VfsEditorPanel extends PureComponent {
isUploading: s.isUploading,
searchQuery: s.searchQuery,
newFileDialogOpen: s.newFileDialogOpen,
newFileDefaultFolder: s.newFileDefaultFolder,
};
}
@@ -49,6 +50,7 @@ export default class VfsEditorPanel extends PureComponent {
isUploading: state.isUploading,
searchQuery: state.searchQuery,
newFileDialogOpen: state.newFileDialogOpen,
newFileDefaultFolder: state.newFileDefaultFolder,
}));
if (this.state.files.length === 0 && !this.state.loadingFiles) {
@@ -180,10 +182,12 @@ export default class VfsEditorPanel extends PureComponent {
searchQuery={searchQuery}
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
onRefresh={() => vfsAutomat.actions.loadFiles()}
onOpenNewDialog={() => vfsAutomat.actions.setNewFileDialogOpen(true)}
onOpenNewDialog={(parentDir) => vfsAutomat.actions.setNewFileDialogOpen(true, parentDir)}
onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)}
onDownload={(name) => vfsAutomat.actions.downloadFile(name)}
onDelete={(name) => vfsAutomat.actions.deleteFile(name)}
onDelete={(name) => vfsAutomat.actions.deletePath(name)}
onRename={(oldPath, newPath) => vfsAutomat.actions.renameFile(oldPath, newPath)}
onMkdir={(dirPath) => vfsAutomat.actions.mkdir(dirPath)}
onUpload={(filesToUpload) => vfsAutomat.actions.uploadFiles(filesToUpload)}
onMentionInChat={this.handleMentionInChat}
/>
@@ -194,6 +198,7 @@ export default class VfsEditorPanel extends PureComponent {
{/* New File Dialog */}
<VfsNewFileDialog
open={newFileDialogOpen}
defaultFolder={this.state.newFileDefaultFolder}
onClose={() => vfsAutomat.actions.setNewFileDialogOpen(false)}
onCreate={(name) => vfsAutomat.actions.createFile(name)}
isCreating={isSaving}

View File

@@ -23,9 +23,13 @@ import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownR
import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import CreateNewFolderRoundedIcon from '@mui/icons-material/CreateNewFolderRounded';
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
import VfsFileIcon from './VfsFileIcon';
import { formatVfsSize, formatVfsDate } from './vfsUtils';
import VfsNewFolderDialog from './VfsNewFolderDialog';
import VfsRenameDialog from './VfsRenameDialog';
/**
* Returns all ancestor directory paths for a file or folder path.
@@ -119,6 +123,14 @@ export default class VfsFileExplorer extends PureComponent {
this.state = {
menuAnchor: null,
menuFile: null,
folderMenuAnchor: null,
folderMenuNode: null,
renameDialogOpen: false,
renameItem: null,
isRenaming: false,
newFolderDialogOpen: false,
targetParentFolder: '',
isCreatingFolder: false,
deleteConfirmFile: null,
isDraggingOver: false,
expandedFolders: initialExpanded,
@@ -168,6 +180,58 @@ export default class VfsFileExplorer extends PureComponent {
this.setState({ menuAnchor: null, menuFile: null });
};
handleOpenFolderMenu = (e, folder) => {
e.stopPropagation();
e.preventDefault();
this.setState({ folderMenuAnchor: e.currentTarget, folderMenuNode: folder });
};
handleCloseFolderMenu = () => {
this.setState({ folderMenuAnchor: null, folderMenuNode: null });
};
handleOpenRename = (item) => {
this.setState({
renameDialogOpen: true,
renameItem: item,
menuAnchor: null,
folderMenuAnchor: null,
folderMenuNode: null,
});
};
handleRenameItem = async (oldPath, newPath) => {
if (!this.props.onRename) return;
this.setState({ isRenaming: true });
try {
await this.props.onRename(oldPath, newPath);
this.setState({ renameDialogOpen: false, renameItem: null, isRenaming: false });
} catch (err) {
this.setState({ isRenaming: false });
alert(err.message || 'Umbenennen fehlgeschlagen');
}
};
handleCreateFolder = async (folderPath) => {
if (!this.props.onMkdir) return;
this.setState({ isCreatingFolder: true });
try {
await this.props.onMkdir(folderPath);
this.setState((prev) => {
const next = new Set(prev.expandedFolders);
next.add(folderPath);
return {
newFolderDialogOpen: false,
isCreatingFolder: false,
expandedFolders: next,
};
});
} catch (err) {
this.setState({ isCreatingFolder: false });
alert(err.message || 'Ordner konnte nicht erstellt werden');
}
};
handleTriggerUpload = () => {
this.fileInputRef.current?.click();
};
@@ -313,6 +377,27 @@ export default class VfsFileExplorer extends PureComponent {
>
{folder.totalFileCount}
</Typography>
{/* Folder menu trigger */}
<Box
onClick={(e) => e.stopPropagation()}
sx={{
display: 'flex',
alignItems: 'center',
ml: 0.5,
opacity: 0.7,
'&:hover': { opacity: 1 },
}}
>
<IconButton
size="small"
onClick={(e) => this.handleOpenFolderMenu(e, folder)}
aria-label="Ordner-Optionen"
sx={{ p: 0.4 }}
>
<MoreVertRoundedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
{/* Folder Children (Directories FIRST, then files) */}
@@ -471,7 +556,19 @@ export default class VfsFileExplorer extends PureComponent {
onSearchChange,
} = this.props;
const { menuAnchor, menuFile, isDraggingOver } = this.state;
const {
menuAnchor,
menuFile,
folderMenuAnchor,
folderMenuNode,
newFolderDialogOpen,
targetParentFolder,
isCreatingFolder,
renameDialogOpen,
renameItem,
isRenaming,
isDraggingOver,
} = this.state;
const isText = menuFile?.isText;
return (
@@ -590,10 +687,20 @@ export default class VfsFileExplorer extends PureComponent {
<CloudUploadRoundedIcon fontSize="small" />
</IconButton>
{/* New Folder Icon Button */}
<IconButton
size="small"
onClick={() => this.setState({ newFolderDialogOpen: true, targetParentFolder: '' })}
aria-label="Neuen Ordner anlegen"
sx={{ p: 0.75 }}
>
<CreateNewFolderRoundedIcon fontSize="small" />
</IconButton>
{/* New File Icon Button (no word) */}
<IconButton
size="small"
onClick={onOpenNewDialog}
onClick={() => onOpenNewDialog?.('')}
aria-label="Neue Datei anlegen"
sx={{ p: 0.75 }}
>
@@ -661,6 +768,25 @@ export default class VfsFileExplorer extends PureComponent {
<ListItemText primary="Pfad kopieren" />
</MenuItem>
<MenuItem
onClick={() => {
const file = menuFile;
this.handleCloseMenu();
if (file) {
this.handleOpenRename({
name: file.name.split('/').pop(),
path: file.name,
isDirectory: false,
});
}
}}
>
<ListItemIcon>
<DriveFileRenameOutlineRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Umbenennen" />
</MenuItem>
{this.props.onMentionInChat && (
<MenuItem onClick={() => this.handleMentionInChat(menuFile)}>
<ListItemIcon>
@@ -685,6 +811,99 @@ export default class VfsFileExplorer extends PureComponent {
<ListItemText primary="Datei löschen" />
</MenuItem>
</Menu>
{/* Folder Context Menu */}
<Menu
anchorEl={folderMenuAnchor}
open={Boolean(folderMenuAnchor)}
onClose={this.handleCloseFolderMenu}
sx={{ zIndex: (theme) => theme.zIndex.modal + 20 }}
PaperProps={{ sx: { borderRadius: 2, minWidth: 190 } }}
>
<MenuItem
onClick={() => {
const folder = folderMenuNode;
this.handleCloseFolderMenu();
this.setState({
newFolderDialogOpen: true,
targetParentFolder: folder ? folder.path : '',
});
}}
>
<ListItemIcon>
<CreateNewFolderRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Neuer Unterordner" />
</MenuItem>
<MenuItem
onClick={() => {
const folder = folderMenuNode;
this.handleCloseFolderMenu();
if (this.props.onOpenNewDialog) {
this.props.onOpenNewDialog(folder ? folder.path : '');
}
}}
>
<ListItemIcon>
<NoteAddRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Neue Datei im Ordner" />
</MenuItem>
<MenuItem
onClick={() => {
const folder = folderMenuNode;
this.handleCloseFolderMenu();
if (folder) {
this.handleOpenRename({
name: folder.name,
path: folder.path,
isDirectory: true,
});
}
}}
>
<ListItemIcon>
<DriveFileRenameOutlineRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Ordner umbenennen" />
</MenuItem>
<MenuItem
onClick={() => {
const folder = folderMenuNode;
this.handleCloseFolderMenu();
if (folder && window.confirm(`Möchtest du den Ordner "${folder.path}" und alle enthaltenen Dateien wirklich löschen?`)) {
this.props.onDelete(folder.path);
}
}}
sx={{ color: 'error.main' }}
>
<ListItemIcon sx={{ color: 'error.main' }}>
<DeleteOutlineRoundedIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Ordner löschen" />
</MenuItem>
</Menu>
{/* New Folder Dialog */}
<VfsNewFolderDialog
open={newFolderDialogOpen}
parentFolder={targetParentFolder}
onClose={() => this.setState({ newFolderDialogOpen: false })}
onCreate={this.handleCreateFolder}
isCreating={isCreatingFolder}
/>
{/* Rename Dialog */}
<VfsRenameDialog
open={renameDialogOpen}
item={renameItem}
onClose={() => this.setState({ renameDialogOpen: false, renameItem: null })}
onRename={this.handleRenameItem}
isRenaming={isRenaming}
/>
</Box>
);
}

View File

@@ -11,15 +11,17 @@ import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
export default class VfsNewFileDialog extends PureComponent {
constructor(props) {
super(props);
const folder = props.defaultFolder ? `${String(props.defaultFolder).replace(/^\/+/, '').replace(/\/+$/, '')}/` : '';
this.state = {
filename: '',
filename: folder,
error: '',
};
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.setState({ filename: '', error: '' });
const folder = this.props.defaultFolder ? `${String(this.props.defaultFolder).replace(/^\/+/, '').replace(/\/+$/, '')}/` : '';
this.setState({ filename: folder, error: '' });
}
}

View File

@@ -0,0 +1,105 @@
import React, { PureComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import CreateNewFolderRoundedIcon from '@mui/icons-material/CreateNewFolderRounded';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
export default class VfsNewFolderDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
folderName: '',
error: '',
};
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.setState({ folderName: '', error: '' });
}
}
handleSubmit = (e) => {
e?.preventDefault();
const raw = this.state.folderName.trim();
if (!raw) {
this.setState({ error: 'Bitte gib einen Ordnernamen ein.' });
return;
}
if (raw.includes('..') || raw.includes('\\')) {
this.setState({ error: 'Ungültige Zeichen im Ordnernamen.' });
return;
}
const clean = raw.replace(/^[/\\]+/, '').replace(/[/\\]+$/, '');
const parent = String(this.props.parentFolder || '').trim().replace(/^[/\\]+/, '').replace(/[/\\]+$/, '');
const fullPath = parent ? `${parent}/${clean}` : clean;
this.props.onCreate?.(fullPath);
};
render() {
const { open, onClose, isCreating, parentFolder } = this.props;
const { folderName, error } = this.state;
const cleanParent = String(parentFolder || '').trim().replace(/^[/\\]+/, '').replace(/[/\\]+$/, '');
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
sx={{ zIndex: (theme) => theme.zIndex.modal + 20 }}
PaperProps={{
sx: { borderRadius: 3, p: 1 },
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.2, pb: 1 }}>
<CreateNewFolderRoundedIcon color="primary" />
<Typography variant="h6" fontWeight={700}>
Neuen Ordner erstellen
</Typography>
</DialogTitle>
<form onSubmit={this.handleSubmit} noValidate autoComplete="off">
<DialogContent sx={{ pt: 1 }}>
{cleanParent && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontFamily: 'monospace' }}>
In: {cleanParent}/
</Typography>
)}
<SingleLineTextEdit
autoFocus
fullWidth
size="small"
label=""
value={folderName}
onChange={(nextVal) => this.setState({ folderName: nextVal, error: '' })}
error={Boolean(error)}
helperText={error}
disabled={isCreating}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, pt: 1 }}>
<Button onClick={onClose} disabled={isCreating} color="inherit">
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={isCreating || !folderName.trim()}
sx={{ borderRadius: 2 }}
>
{isCreating ? 'Erstelle…' : 'Erstellen'}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
}

View File

@@ -0,0 +1,107 @@
import React, { PureComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
export default class VfsRenameDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
targetPath: props.item?.path || '',
error: '',
};
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.setState({
targetPath: this.props.item?.path || '',
error: '',
});
}
}
handleSubmit = (e) => {
e?.preventDefault();
const raw = this.state.targetPath.trim();
if (!raw) {
this.setState({ error: 'Bitte gib einen neuen Namen bzw. Pfad ein.' });
return;
}
if (raw.includes('..') || raw.includes('\\')) {
this.setState({ error: 'Ungültige Zeichen im Pfad.' });
return;
}
const cleanNew = raw.replace(/^[/\\]+/, '').replace(/[/\\]+$/, '');
const cleanOld = String(this.props.item?.path || '').replace(/^[/\\]+/, '').replace(/[/\\]+$/, '');
if (cleanNew === cleanOld) {
this.props.onClose?.();
return;
}
this.props.onRename?.(cleanOld, cleanNew);
};
render() {
const { open, onClose, isRenaming, item } = this.props;
const { targetPath, error } = this.state;
const isDir = Boolean(item?.isDirectory);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="xs"
fullWidth
sx={{ zIndex: (theme) => theme.zIndex.modal + 20 }}
PaperProps={{
sx: { borderRadius: 3, p: 1 },
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.2, pb: 1 }}>
<DriveFileRenameOutlineRoundedIcon color="primary" />
<Typography variant="h6" fontWeight={700}>
{isDir ? 'Ordner umbenennen' : 'Datei umbenennen'}
</Typography>
</DialogTitle>
<form onSubmit={this.handleSubmit} noValidate autoComplete="off">
<DialogContent sx={{ pt: 1 }}>
<SingleLineTextEdit
autoFocus
fullWidth
size="small"
label=""
value={targetPath}
onChange={(nextVal) => this.setState({ targetPath: nextVal, error: '' })}
error={Boolean(error)}
helperText={error}
disabled={isRenaming}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, pt: 1 }}>
<Button onClick={onClose} disabled={isRenaming} color="inherit">
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={isRenaming || !targetPath.trim()}
sx={{ borderRadius: 2 }}
>
{isRenaming ? 'Speichere…' : 'Umbenennen'}
</Button>
</DialogActions>
</form>
</Dialog>
);
}
}

45
test/README.md Normal file
View File

@@ -0,0 +1,45 @@
# Visual testing rig
Dummy backend + frozen fixtures + screenshot reference/compare. No npm deps
beyond the repo (uses system Chrome at `/usr/bin/google-chrome`).
## Workflow
```bash
# Build reference screenshots from live data (:4208 must run in dashback).
# Collects fixtures, then captures 21 routes × 2 viewports into
# test/screenshots/reference/ (committed to git).
npm run rig:reference
# Capture fresh screenshots and compare against reference (all in one).
# Fails when > 0.1% of pixels differ; diffs → test/screenshots/diff/
npm run rig:compare
```
## Viewports
- `1920x1200`, `1280x960` (see `test/routes.mjs`)
## Files
| File | Role |
|---|---|
| `test/routes.mjs` | Route + viewport definitions (concrete ids: `ART-000001`, delivery `6`, entity `1`) |
| `test/collect.mjs` | Fixture collector (mints JWT via dashback env + signing lib) |
| `test/dummyBackend.mjs` | Always-authed stub serving `test/fixtures/snapshot.json` + `images/` |
| `test/cdp.mjs` | Zero-dep CDP client (fresh page target per shot, settle wait, PNG capture) |
| `test/capture.mjs` | Orchestrates dummy + vite + Chrome |
| `test/compare.mjs` | Zero-dep PNG decoder + comparator (0.1% tolerance, red diff PNGs) |
| `test/fixtures/` | Committed snapshot + binary thumbs (2.5 MiB) |
| `test/screenshots/reference/` | Committed reference PNGs (7.5 MiB) |
## Notes
- `vite.config.js` honors `RIG_API_TARGET` for the `/api` proxy (default `http://localhost:4208`),
so the rig runs alongside live dev servers on :4208/:4209.
- The dummy holds `/api/events` open with heartbeats and never emits — fixtures stay frozen.
- Mutations (POST/PATCH/PUT/DELETE) echo fixtures so UI actions don't crash mid-run.
- `GET /api/history` and `GET /api/article/history` don't exist in the backend yet
(frontend already renders empty grids); the collector stores empty fixtures for them.
- Capture waits for `#root` content > 20 KB with zero `[role=progressbar]` spinners,
plus a settle delay, then hides carets/scrollbars for determinism.

152
test/capture.mjs Normal file
View File

@@ -0,0 +1,152 @@
/**
* Capture screenshots for every route × viewport against the dummy backend.
*
* Usage:
* node test/capture.mjs [--out test/screenshots/current]
*
* Flow: spawns the dummy backend (default :4218) + `vite --port 4229` with
* RIG_API_TARGET pointing at the dummy, then drives a persistent headless
* Chrome (remote-debugging port) via CDP: one fresh page target per shot,
* waits for React hydration + data, captures full-page PNGs. No npm deps.
*
* The dummy backend is always-authed, so no login step is needed.
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ROUTES, VIEWPORTS } from './routes.mjs';
import { capturePage } from './cdp.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRONT_ROOT = path.resolve(__dirname, '..');
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, v] = a.split('=');
return [k.replace(/^--/, ''), v ?? true];
}),
);
const OUT_DIR = path.resolve(FRONT_ROOT, args.out ?? 'test/screenshots/current');
const DUMMY_PORT = Number(args['dummy-port'] ?? 4218);
const VITE_PORT = Number(args['vite-port'] ?? 4229);
const DEBUG_PORT = Number(args['debug-port'] ?? 9333);
const BASE = `http://localhost:${VITE_PORT}`;
const SETTLE_MS = Number(args.settle ?? 2500);
const CHROME = args.chrome ?? '/usr/bin/google-chrome';
function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function waitForPort(port, host = '127.0.0.1', timeoutMs = 45000) {
const net = await import('node:net');
const start = Date.now();
for (;;) {
try {
await new Promise((resolve, reject) => {
const s = net.default.connect(port, host);
s.on('connect', () => {
s.end();
resolve();
});
s.on('error', reject);
});
return;
} catch {
if (Date.now() - start > timeoutMs) throw new Error(`port ${port} not ready`);
await wait(300);
}
}
}
function startProc(cmd, procArgs, env, tag) {
const child = spawn(cmd, procArgs, {
cwd: FRONT_ROOT,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (d) => process.stdout.write(`[${tag}] ${d}`));
child.stderr.on('data', (d) => {
const text = String(d);
// vite proxy logs are noise during capture; keep errors.
if (tag === 'vite' && text.includes('[vite-proxy]')) return;
process.stderr.write(`[${tag}] ${d}`);
});
return child;
}
async function main() {
if (!fs.existsSync(CHROME)) {
throw new Error(`Chrome not found at ${CHROME} (pass --chrome=...)`);
}
if (!fs.existsSync(path.join(FRONT_ROOT, 'test', 'fixtures', 'snapshot.json'))) {
throw new Error('Missing test/fixtures/snapshot.json — run: npm run rig:collect');
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const dummy = startProc('node', ['test/dummyBackend.mjs', `--port=${DUMMY_PORT}`], {}, 'dummy');
await waitForPort(DUMMY_PORT);
const vite = startProc(
'npx',
['vite', '--port', String(VITE_PORT), '--strictPort'],
{ RIG_API_TARGET: `http://localhost:${DUMMY_PORT}` },
'vite',
);
await waitForPort(VITE_PORT);
await wait(3000);
const profile = '/tmp/rig-chrome-profile';
fs.mkdirSync(profile, { recursive: true });
const chrome = startProc(
CHROME,
[
'--headless=new',
'--no-sandbox',
'--disable-gpu',
'--hide-scrollbars',
`--remote-debugging-port=${DEBUG_PORT}`,
`--user-data-dir=${profile}`,
'about:blank',
],
{},
'chrome',
);
await waitForPort(DEBUG_PORT);
let failed = 0;
try {
for (const viewport of VIEWPORTS) {
for (const route of ROUTES) {
const file = path.join(OUT_DIR, `${route.name}.${viewport.name}.png`);
try {
await capturePage(DEBUG_PORT, `${BASE}${route.path}`, file, {
width: viewport.width,
height: viewport.height,
settleMs: SETTLE_MS,
});
const kb = (fs.statSync(file).size / 1024).toFixed(0);
console.log(`[capture] ${route.path} @ ${viewport.name} → ${path.basename(file)} (${kb} KiB)`);
} catch (err) {
console.error(`[capture] FAIL ${route.path} @ ${viewport.name}: ${err.message}`);
failed++;
}
}
}
} finally {
chrome.kill();
vite.kill();
dummy.kill();
}
if (failed > 0) {
console.error(`[capture] ${failed} shots failed`);
process.exit(1);
}
console.log(`[capture] done → ${OUT_DIR}`);
}
main().catch((err) => {
console.error(`[capture] FAILED: ${err.message}`);
process.exit(1);
});

215
test/cdp.mjs Normal file
View File

@@ -0,0 +1,215 @@
/**
* CDP helpers for the visual testing rig (no npm deps).
* Connects to a Chrome remote-debugging port, opens a fresh page target per
* shot, waits for the app to settle, captures a full-page PNG.
*/
import net from 'node:net';
import fs from 'node:fs';
function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
class CdpConnection {
constructor(socket) {
this.socket = socket;
this.id = 0;
this.pending = new Map();
this.buf = Buffer.alloc(0);
this.handlers = new Map();
socket.on('data', (chunk) => this.onData(chunk));
}
onData(chunk) {
this.buf = Buffer.concat([this.buf, chunk]);
for (;;) {
if (this.buf.length < 2) return;
const opcode = this.buf[0] & 0x0f;
let len = this.buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (this.buf.length < 4) return;
len = this.buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (this.buf.length < 10) return;
len = Number(this.buf.readBigUInt64BE(2));
off = 10;
}
if ((this.buf[1] & 0x80) !== 0) off += 4;
if (this.buf.length < off + len) return;
const payload = this.buf.subarray(off, off + len);
this.buf = this.buf.subarray(off + len);
if (opcode === 0x8) {
this.socket.end();
return;
}
if (opcode !== 0x1) continue;
let msg;
try {
msg = JSON.parse(payload.toString('utf8'));
} catch {
continue;
}
if (msg.id != null && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`));
else resolve(msg.result);
} else if (msg.method && this.handlers.has(msg.method)) {
for (const fn of this.handlers.get(msg.method)) {
try {
fn(msg.params);
} catch {
// ignore
}
}
}
}
}
sendFrame(opcode, payload) {
const len = payload.length;
let header;
if (len < 126) header = Buffer.from([0x80 | opcode, 0x80 | len, 0, 0, 0, 0]);
else if (len < 65536) {
header = Buffer.alloc(8);
header[0] = 0x80 | opcode;
header[1] = 0x80 | 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(14);
header[0] = 0x80 | opcode;
header[1] = 0x80 | 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
this.socket.write(Buffer.concat([header, payload]));
}
send(method, params = {}, timeoutMs = 30000) {
const callId = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(callId, { resolve, reject });
this.sendFrame(0x1, Buffer.from(JSON.stringify({ id: callId, method, params })));
setTimeout(() => {
if (this.pending.has(callId)) {
this.pending.delete(callId);
reject(new Error(`CDP timeout: ${method}`));
}
}, timeoutMs);
});
}
on(method, fn) {
if (!this.handlers.has(method)) this.handlers.set(method, new Set());
this.handlers.get(method).add(fn);
return () => this.handlers.get(method)?.delete(fn);
}
close() {
try {
this.sendFrame(0x8, Buffer.alloc(0));
} catch {
// ignore
}
this.socket.end();
}
}
export async function cdpConnect(wsUrl) {
const url = new URL(wsUrl);
const key = Buffer.from(`rig-${Date.now()}`).toString('base64');
const socket = net.connect(Number(url.port), url.hostname);
await new Promise((resolve, reject) => {
socket.on('connect', () => {
socket.write(
`GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.hostname}:${url.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
);
});
socket.on('error', reject);
let head = '';
const onData = (chunk) => {
head += chunk.toString('latin1');
if (head.includes('\r\n\r\n')) {
socket.off('data', onData);
resolve();
}
};
socket.on('data', onData);
});
return new CdpConnection(socket);
}
async function httpJson(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return res.json();
}
/**
* Opens a new page target on the browser, navigates, waits for the app root
* to contain content + network to quiet down, captures a PNG.
*/
export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000 }) {
// Chrome ≥136 requires PUT for /json/new.
const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, {
method: 'PUT',
});
const session = await cdpConnect(target.webSocketDebuggerUrl);
try {
await session.send('Page.enable');
await session.send('Runtime.enable');
await session.send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 1,
mobile: false,
});
await session.send('Page.navigate', { url });
// Wait until #root has substantial content (React hydrated + data loaded).
const deadline = Date.now() + 45000;
for (;;) {
const res = await session.send(
'Runtime.evaluate',
{
expression: `(() => {
const root = document.getElementById('root');
const len = root ? root.innerHTML.length : 0;
const spinners = document.querySelectorAll('[role="progressbar"]').length;
return { len, spinners };
})()`,
returnByValue: true,
},
15000,
);
const { len, spinners } = res.result?.value ?? { len: 0, spinners: 99 };
if (len > 20000 && spinners === 0) break;
if (Date.now() > deadline) {
console.warn(`[capture] settle timeout for ${url} (root=${len}, spinners=${spinners})`);
break;
}
await wait(500);
}
// Extra quiet period for images / thumbhash decode / layout.
await wait(settleMs);
// Determinism: hide carets and scrollbars.
await session.send('Runtime.evaluate', {
expression: `(() => { const s = document.createElement('style');
s.textContent = '*{caret-color:transparent !important} ::-webkit-scrollbar{display:none !important}';
document.head.appendChild(s); })()`,
});
await wait(400);
const { data } = await session.send(
'Page.captureScreenshot',
{ format: 'png', captureBeyondViewport: true, fromSurface: true },
30000,
);
fs.writeFileSync(file, Buffer.from(data, 'base64'));
} finally {
session.close();
await fetch(`http://127.0.0.1:${debugPort}/json/close/${target.id}`).catch(() => {});
}
}

240
test/collect.mjs Normal file
View File

@@ -0,0 +1,240 @@
/**
* Collects a deterministic fixture snapshot from the live testing backend.
*
* Usage:
* npm run rig:reference (runs collect first, then captures)
*
* Reads ../dashback/.env for GOOGLE_CLIENT_ID (stored as googleClientId in the
* snapshot) and mints a JWT for user id 1 with the backend's own env + signing
* library, then GETs every endpoint the dummy backend needs to serve.
* Writes test/fixtures/snapshot.json (plus images/ for binary thumbs).
*
* The live backend must run at http://localhost:4208 (npm run dev in dashback).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRONT_ROOT = path.resolve(__dirname, '..');
const BACK_ROOT = path.resolve(FRONT_ROOT, '../dashback');
const FIXTURE_DIR = path.join(FRONT_ROOT, 'test', 'fixtures');
const IMAGE_DIR = path.join(FIXTURE_DIR, 'images');
const BACKEND = process.env.RIG_BACKEND ?? 'http://localhost:4208';
function parseDotEnv(text) {
const out = {};
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
return out;
}
async function mintToken() {
const env = await import(path.join(BACK_ROOT, 'src/config/env.js')).then(
(m) => m.default,
);
const { default: jwt } = await import(
path.join(BACK_ROOT, 'node_modules/jsonwebtoken/index.js')
);
return {
token: jwt.sign({ id: 1 }, env.jwtSecret, { expiresIn: '1d' }),
googleClientId: env.googleClientId ?? null,
};
}
async function get(token, p, { binary = false } = {}) {
const res = await fetch(`${BACKEND}${p}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`GET ${p} → ${res.status} ${text.slice(0, 200)}`);
}
if (binary) return Buffer.from(await res.arrayBuffer());
return res.json();
}
async function getOptional(token, p) {
try {
return await get(token, p);
} catch (err) {
console.warn(`[rig:collect] skip ${p}: ${err.message}`);
return null;
}
}
const KNOWN_SKU = 'ART-000001';
const KNOWN_DELIVERY = 6;
const KNOWN_ENTITY = 1;
async function main() {
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
fs.mkdirSync(IMAGE_DIR, { recursive: true });
// GOOGLE_CLIENT_ID comes from the testing backend env (../dashback/.env).
let dotenvClientId = null;
try {
const raw = fs.readFileSync(path.join(BACK_ROOT, '.env'), 'utf8');
dotenvClientId = parseDotEnv(raw).GOOGLE_CLIENT_ID ?? null;
} catch {
console.warn('[rig:collect] ../dashback/.env not readable, using backend env module');
}
const { token, googleClientId } = await mintToken();
const me = await get(token, '/api/auth/me');
const user = me.user;
const companyId = user.selectedCompanyId;
const snapshot = {
meta: {
collectedAt: new Date().toISOString(),
backend: BACKEND,
companyId,
googleClientId: dotenvClientId ?? googleClientId,
},
auth: {
me: { user, googleClientId: dotenvClientId ?? googleClientId },
companies: (await get(token, '/api/company')).companies ?? [],
},
company: {},
catalog: {},
parties: {},
documents: {},
misc: {},
images: {},
};
// Company detail is fetched per-id via GET /api/company (list) — the
// frontend's companyApi.getById() filters the list client-side.
snapshot.company.detail =
snapshot.auth.companies.find((c) => c.id === companyId) ?? null;
snapshot.company.settings =
(await getOptional(token, '/api/company/setting'))?.settings ?? [];
// Catalog
const tree = await get(token, '/api/category/tree');
snapshot.catalog.tree = tree;
const categoryIds = (tree.tree ?? []).map((t) => t.id);
snapshot.catalog.articlesByCategory = {};
for (const id of categoryIds) {
const data = await getOptional(token, `/api/category/${id}/articles`);
snapshot.catalog.articlesByCategory[String(id)] = data?.articles ?? [];
}
snapshot.catalog.articleDetail = (
await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}`)
)?.article ?? null;
snapshot.catalog.articleHistory =
(await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}/history?limit=50`))?.history ?? [];
snapshot.catalog.articleSuppliers =
(await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}/supplier`))?.suppliers ?? [];
snapshot.catalog.brands = (await getOptional(token, '/api/brand'))?.brands ?? [];
snapshot.catalog.units = (await getOptional(token, '/api/unit'))?.units ?? [];
snapshot.catalog.warehouses =
(await getOptional(token, '/api/warehouse'))?.warehouses ?? [];
snapshot.catalog.attributes =
(await getOptional(token, '/api/attributename'))?.attributenames ?? [];
snapshot.catalog.properties =
(await getOptional(token, '/api/propertyname'))?.propertynames ?? [];
// Parties
snapshot.parties.suppliers =
(await getOptional(token, '/api/supplier'))?.suppliers ?? [];
snapshot.parties.entities =
(await getOptional(token, '/api/entity'))?.entities ?? [];
snapshot.parties.entityDetail =
(await getOptional(token, `/api/entity/${KNOWN_ENTITY}`))?.entity ?? null;
snapshot.parties.entityAddresses =
(await getOptional(token, `/api/entity/${KNOWN_ENTITY}/address`))?.addresses ?? [];
snapshot.parties.entityContacts =
(await getOptional(token, `/api/entity/${KNOWN_ENTITY}/contact`))?.contacts ?? [];
snapshot.parties.customergroups =
(await getOptional(token, '/api/customergroup'))?.customergroups ?? [];
// Documents
snapshot.documents.deliveries =
(await getOptional(token, '/api/delivery'))?.deliveries ?? [];
snapshot.documents.deliveryDetail =
(await getOptional(token, `/api/delivery/${KNOWN_DELIVERY}`))?.delivery ?? null;
snapshot.documents.quotes = (await getOptional(token, '/api/quote'))?.quotes ?? [];
snapshot.documents.orders = (await getOptional(token, '/api/order'))?.orders ?? [];
snapshot.documents.numbers =
(await getOptional(token, '/api/number'))?.numbers ?? [];
// Misc
snapshot.misc.posterminals =
(await getOptional(token, '/api/posterminal'))?.posterminals ?? [];
snapshot.misc.printers = (await getOptional(token, '/api/printer'))?.printers ?? [];
snapshot.misc.labeltemplates =
(await getOptional(token, '/api/labeltemplate'))?.labeltemplates ?? [];
snapshot.misc.vfs = (await getOptional(token, '/api/vfs'))?.files ?? [];
snapshot.misc.globalHistory =
(await getOptional(token, '/api/history?limit=50'))?.history ??
(await getOptional(token, '/api/history?limit=50'))?.rows ??
[];
snapshot.misc.articleHistoryGrid =
(await getOptional(token, '/api/article/history?limit=50'))?.history ??
(await getOptional(token, '/api/article/history?limit=50'))?.rows ??
[];
// Binary thumbs: collect every fullHash referenced by articles/categories/brands
// so /api/thumb/:hash and /api/image/:hash serve byte-identical images.
// Backend fullHash is an 8-char alphanumeric string (see dashback pic.js).
const hashes = new Set();
const collectHash = (h) => {
if (typeof h === 'string' && /^[A-Za-z0-9]{8}$/.test(h)) hashes.add(h);
};
for (const list of Object.values(snapshot.catalog.articlesByCategory)) {
for (const a of list) collectHash(a.fullHash);
}
for (const t of tree.tree ?? []) collectHash(t.fullHash);
for (const b of snapshot.catalog.brands) {
collectHash(b.fullHash);
for (const p of b.pics ?? []) collectHash(p.fullHash);
}
for (const p of snapshot.catalog.articleDetail?.photos ?? []) collectHash(p.fullHash);
let imageBytes = 0;
for (const hash of hashes) {
try {
const buf = await get(token, `/api/thumb/${hash}`, { binary: true });
fs.writeFileSync(path.join(IMAGE_DIR, `${hash}.avif`), buf);
snapshot.images[hash] = `${hash}.avif`;
imageBytes += buf.length;
} catch (err) {
console.warn(`[rig:collect] thumb ${hash.slice(0, 12)}…: ${err.message}`);
}
}
fs.writeFileSync(
path.join(FIXTURE_DIR, 'snapshot.json'),
`${JSON.stringify(snapshot, null, 2)}\n`,
);
const counts = {
categories: categoryIds.length,
categoryArticles: Object.values(snapshot.catalog.articlesByCategory).reduce(
(n, l) => n + l.length,
0,
),
brands: snapshot.catalog.brands.length,
images: Object.keys(snapshot.images).length,
deliveries: snapshot.documents.deliveries.length,
entities: snapshot.parties.entities.length,
};
console.log(
`[rig:collect] snapshot.json + ${counts.images} images ` +
`(${(imageBytes / 1024).toFixed(0)} KiB): ${JSON.stringify(counts)}`,
);
}
main().catch((err) => {
console.error(`[rig:collect] FAILED: ${err.message}`);
process.exit(1);
});

263
test/compare.mjs Normal file
View File

@@ -0,0 +1,263 @@
/**
* Compare current screenshots against the reference set with very low tolerance.
*
* Usage:
* node test/compare.mjs [--ref test/screenshots/reference] [--cur test/screenshots/current]
* [--tolerance 0.001] [--diff test/screenshots/diff]
*
* Pure Node.js PNG decoder + comparator (no npm deps): parses PNG (8-bit
* RGB/RGBA, non-interlaced — what Chrome headless writes), compares pixels
* with per-channel tolerance, fails when the fraction of differing pixels
* exceeds --tolerance (default 0.1%).
*
* Exit 0 when all match, 1 with a per-file report otherwise. Writes red-tinted
* diff PNGs for mismatches when sizes are equal.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRONT_ROOT = path.resolve(__dirname, '..');
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, v] = a.split('=');
return [k.replace(/^--/, ''), v ?? true];
}),
);
const REF_DIR = path.resolve(FRONT_ROOT, args.ref ?? 'test/screenshots/reference');
const CUR_DIR = path.resolve(FRONT_ROOT, args.cur ?? 'test/screenshots/current');
const DIFF_DIR = path.resolve(FRONT_ROOT, args.diff ?? 'test/screenshots/diff');
const TOLERANCE = Number(args.tolerance ?? 0.001); // fraction of pixels allowed to differ
const CHANNEL_TOL = Number(args['channel-tol'] ?? 8); // per-channel 0-255 tolerance
function parsePng(buf) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!buf.subarray(0, 8).equals(sig)) throw new Error('not a PNG');
let off = 8;
let width;
let height;
let bitDepth;
let colorType;
let interlace;
const idat = [];
while (off < buf.length) {
const len = buf.readUInt32BE(off);
const type = buf.toString('ascii', off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === 'IHDR') {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
interlace = data[12];
} else if (type === 'IDAT') {
idat.push(data);
} else if (type === 'IEND') {
break;
}
off += 12 + len;
}
if (bitDepth !== 8 || interlace !== 0 || ![2, 6].includes(colorType)) {
throw new Error(`unsupported PNG (depth=${bitDepth} type=${colorType} interlace=${interlace})`);
}
const channels = colorType === 6 ? 4 : 3;
const raw = zlib.inflateSync(Buffer.concat(idat));
const stride = width * channels;
const pixels = Buffer.alloc(width * height * channels);
let p = 0;
let prev = Buffer.alloc(stride);
for (let y = 0; y < height; y++) {
const filter = raw[p++];
const cur = raw.subarray(p, p + stride);
p += stride;
const out = Buffer.alloc(stride);
for (let i = 0; i < stride; i++) {
const a = i >= channels ? out[i - channels] : 0;
const b = prev[i];
const c = i >= channels ? prev[i - channels] : 0;
let v;
switch (filter) {
case 0: v = cur[i]; break;
case 1: v = (cur[i] + a) & 255; break;
case 2: v = (cur[i] + b) & 255; break;
case 3: v = (cur[i] + ((a + b) >> 1)) & 255; break;
case 4: {
const pa = Math.abs(b - c);
const pb = Math.abs(a - c);
const pc = Math.abs(a + b - 2 * c);
const pr = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
v = (cur[i] + pr) & 255;
break;
}
default: throw new Error(`bad filter ${filter}`);
}
out[i] = v;
}
out.copy(pixels, y * stride);
prev = out;
}
return { width, height, channels, pixels };
}
function crc32(buf) {
let table = crc32.table;
if (!table) {
table = crc32.table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
}
let crc = -1;
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 255];
return (crc ^ -1) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
function encodePng(width, height, rgba) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0;
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([
sig,
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
function toRgba(parsed) {
if (parsed.channels === 4) return parsed.pixels;
const out = Buffer.alloc(parsed.width * parsed.height * 4);
for (let i = 0, j = 0; i < parsed.pixels.length; i += 3, j += 4) {
out[j] = parsed.pixels[i];
out[j + 1] = parsed.pixels[i + 1];
out[j + 2] = parsed.pixels[i + 2];
out[j + 3] = 255;
}
return out;
}
function compareFiles(refFile, curFile) {
const ref = parsePng(fs.readFileSync(refFile));
const cur = parsePng(fs.readFileSync(curFile));
if (ref.width !== cur.width || ref.height !== cur.height) {
return {
ok: false,
reason: `size mismatch ref=${ref.width}x${ref.height} cur=${cur.width}x${cur.height}`,
};
}
const a = toRgba(ref);
const b = toRgba(cur);
const total = ref.width * ref.height;
let diff = 0;
let maxDelta = 0;
const diffImg = Buffer.alloc(a.length);
for (let i = 0; i < total; i++) {
const o = i * 4;
const d = Math.max(
Math.abs(a[o] - b[o]),
Math.abs(a[o + 1] - b[o + 1]),
Math.abs(a[o + 2] - b[o + 2]),
);
if (d > maxDelta) maxDelta = d;
if (d > CHANNEL_TOL) {
diff++;
diffImg[o] = 255;
diffImg[o + 1] = 0;
diffImg[o + 2] = 0;
diffImg[o + 3] = 255;
} else {
// dim grayscale of reference for context
const g = Math.round((a[o] + a[o + 1] + a[o + 2]) / 6);
diffImg[o] = g;
diffImg[o + 1] = g;
diffImg[o + 2] = g;
diffImg[o + 3] = 255;
}
}
const frac = diff / total;
return {
ok: frac <= TOLERANCE,
diffPixels: diff,
totalPixels: total,
frac,
maxDelta,
diffImg: diff > 0 ? encodePng(ref.width, ref.height, diffImg) : null,
};
}
function main() {
if (!fs.existsSync(REF_DIR)) {
console.error(`[compare] no reference dir ${REF_DIR} — run: npm run rig:reference`);
process.exit(1);
}
if (!fs.existsSync(CUR_DIR)) {
console.error(`[compare] no current dir ${CUR_DIR} — run: npm run rig:screenshots`);
process.exit(1);
}
fs.mkdirSync(DIFF_DIR, { recursive: true });
const files = fs.readdirSync(REF_DIR).filter((f) => f.endsWith('.png')).sort();
if (!files.length) {
console.error('[compare] reference dir is empty');
process.exit(1);
}
let failed = 0;
for (const f of files) {
const cur = path.join(CUR_DIR, f);
if (!fs.existsSync(cur)) {
console.log(`[compare] MISSING ${f} (no current screenshot)`);
failed++;
continue;
}
let result;
try {
result = compareFiles(path.join(REF_DIR, f), cur);
} catch (err) {
console.log(`[compare] ERROR ${f}: ${err.message}`);
failed++;
continue;
}
if (result.ok) {
console.log(
`[compare] OK ${f} (${result.diffPixels}/${result.totalPixels} px, maxΔ=${result.maxDelta})`,
);
} else {
failed++;
const detail = result.reason
?? `${result.diffPixels}/${result.totalPixels} px differ (${(result.frac * 100).toFixed(3)}% > ${(TOLERANCE * 100).toFixed(3)}%), maxΔ=${result.maxDelta}`;
console.log(`[compare] FAIL ${f}: ${detail}`);
if (result.diffImg) {
fs.writeFileSync(path.join(DIFF_DIR, f), result.diffImg);
}
}
}
if (failed > 0) {
console.log(`[compare] ${failed}/${files.length} mismatched (tolerance ${(TOLERANCE * 100).toFixed(2)}%, channel±${CHANNEL_TOL}). Diffs → ${DIFF_DIR}`);
process.exit(1);
}
console.log(`[compare] all ${files.length} screenshots match (tolerance ${(TOLERANCE * 100).toFixed(2)}%, channel±${CHANNEL_TOL})`);
}
main();

490
test/dummyBackend.mjs Normal file
View File

@@ -0,0 +1,490 @@
/**
* Dummy backend for the visual testing rig.
*
* Serves the frozen fixture snapshot (test/fixtures/snapshot.json) on every
* GET endpoint the frontend reads during screenshot runs. Always-authed stub:
* every request is treated as the fixture user, so headless --screenshot runs
* need no cookie juggling. (There is no logged-out/login screenshot.)
*
* Mutating methods (POST/PATCH/PUT/DELETE) return the stored fixture payload
* (or a minimal echo) so UI actions don't crash mid-run — persistence is
* in-memory only and resets on restart.
*
* Usage: node test/dummyBackend.mjs [--port 4218]
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_DIR = path.join(__dirname, 'fixtures');
const IMAGE_DIR = path.join(FIXTURE_DIR, 'images');
const portArg = process.argv.find((a) => a.startsWith('--port='))?.split('=')[1];
const PORT = Number(portArg ?? process.env.RIG_BACKEND_PORT ?? 4208);
function loadSnapshot() {
const file = path.join(FIXTURE_DIR, 'snapshot.json');
if (!fs.existsSync(file)) {
console.error(
`[dummy-backend] missing ${file}. Run: npm run rig:collect (live backend at :4208 required)`,
);
process.exit(1);
}
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
const snap = loadSnapshot();
const { auth, company, catalog, parties, documents, misc } = snap;
const googleClientId = snap.meta?.googleClientId ?? null;
const user = auth.me.user;
function json(res, status, body) {
const text = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(text),
'Cache-Control': 'no-store',
});
res.end(text);
}
function readBody(req) {
return new Promise((resolve) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return resolve(null);
try {
resolve(JSON.parse(raw));
} catch {
resolve(raw);
}
});
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const p = url.pathname;
const method = req.method;
// Public / auth bootstrap. The dummy is always-authed: every request is
// treated as the fixture user, so headless --screenshot runs need no
// cookie juggling. (There is no logged-out/login screenshot in the rig.)
if (p === '/api/health') return json(res, 200, { ok: true });
if (p === '/api/auth/me' && method === 'GET') {
return json(res, 200, { user, googleClientId });
}
if (p === '/api/auth/login' && method === 'POST') {
await readBody(req);
res.setHeader('Set-Cookie', 'token=dummy-rig-token; Path=/; HttpOnly');
return json(res, 200, { message: 'Login successful', user });
}
if (p === '/api/auth/logout' && method === 'POST') {
res.setHeader('Set-Cookie', 'token=; Path=/; HttpOnly; Max-Age=0');
return json(res, 200, { ok: true });
}
if (p === '/api/auth/selected-company' && method === 'PUT') {
return json(res, 200, { user });
}
// Everything else is served as the fixture user (always-authed stub).
// SSE: hold the stream open with heartbeats, never emit (frozen fixtures).
if (p === '/api/events' && method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
const beat = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch {
clearInterval(beat);
}
}, 20000);
req.on('close', () => clearInterval(beat));
return;
}
// Binary images (byte-identical to the live backend at collect time).
// Backend fullHash is an 8-char alphanumeric string (see dashback pic.js).
const thumbMatch = p.match(/^\/api\/thumb\/([A-Za-z0-9]{8})$/);
if (thumbMatch && method === 'GET') {
const file = path.join(IMAGE_DIR, `${thumbMatch[1]}.avif`);
if (!fs.existsSync(file)) return json(res, 404, { error: 'Image not found' });
const buf = fs.readFileSync(file);
res.writeHead(200, {
'Content-Type': 'image/avif',
'Content-Length': buf.length,
'Cache-Control': 'private, max-age=31536000, immutable',
ETag: `"${thumbMatch[1]}"`,
});
return res.end(buf);
}
const imageMatch = p.match(/^\/api\/image\/([A-Za-z0-9]{8})$/);
if (imageMatch && method === 'GET') {
const file = path.join(IMAGE_DIR, `${imageMatch[1]}.avif`);
if (!fs.existsSync(file)) return json(res, 404, { error: 'Image not found' });
const buf = fs.readFileSync(file);
res.writeHead(200, {
'Content-Type': 'image/avif',
'Content-Length': buf.length,
'Cache-Control': 'private, max-age=31536000, immutable',
ETag: `"${imageMatch[1]}"`,
});
return res.end(buf);
}
// ── Reads ──────────────────────────────────────────────
if (method === 'GET') {
if (p === '/api/company') return json(res, 200, { companies: auth.companies });
if (p === '/api/category/tree') return json(res, 200, catalog.tree);
const catArticles = p.match(/^\/api\/category\/(\d+)\/articles$/);
if (catArticles) {
return json(res, 200, {
articles: catalog.articlesByCategory[catArticles[1]] ?? [],
});
}
const articleMatch = p.match(/^\/api\/article\/([^/]+)$/);
if (articleMatch && !['search', 'history', 'export.zip'].includes(articleMatch[1])) {
const sku = decodeURIComponent(articleMatch[1]);
if (sku === catalog.articleDetail?.sku) return json(res, 200, { article: catalog.articleDetail });
// Any other SKU: clone the known detail so deep links still render.
if (catalog.articleDetail) {
return json(res, 200, {
article: { ...catalog.articleDetail, sku, name: catalog.articleDetail.name },
});
}
return json(res, 404, { error: 'Article not found' });
}
if (p === '/api/article/history') return json(res, 200, { history: misc.articleHistoryGrid ?? [] });
const skuHistory = p.match(/^\/api\/article\/([^/]+)\/history$/);
if (skuHistory) return json(res, 200, { history: catalog.articleHistory ?? [] });
const skuSuppliers = p.match(/^\/api\/article\/([^/]+)\/supplier$/);
if (skuSuppliers) return json(res, 200, { suppliers: catalog.articleSuppliers ?? [] });
const skuChildren = p.match(/^\/api\/article\/([^/]+)\/children$/);
if (skuChildren) return json(res, 200, { children: [] });
const skuAttr = p.match(/^\/api\/article\/([^/]+)\/attributevalue$/);
if (skuAttr) return json(res, 200, { attributevalues: [] });
const skuProp = p.match(/^\/api\/article\/([^/]+)\/property$/);
if (skuProp) return json(res, 200, { properties: [] });
const skuArticlelist = p.match(/^\/api\/article\/([^/]+)\/articlelist$/);
if (skuArticlelist) return json(res, 200, { items: [] });
const skuOptions = p.match(/^\/api\/article\/([^/]+)\/optionslist$/);
if (skuOptions) return json(res, 200, { options: [] });
if (p === '/api/brand') return json(res, 200, { brands: catalog.brands ?? [] });
if (p === '/api/unit') return json(res, 200, { units: catalog.units ?? [] });
if (p === '/api/warehouse') return json(res, 200, { warehouses: catalog.warehouses ?? [] });
const warehouseById = p.match(/^\/api\/warehouse\/(\d+)$/);
if (warehouseById) {
const found = (catalog.warehouses ?? []).find((w) => String(w.id) === warehouseById[1]);
if (!found) return json(res, 404, { error: 'Warehouse not found' });
return json(res, 200, { warehouse: found });
}
if (p === '/api/attributename') return json(res, 200, { attributenames: catalog.attributes ?? [] });
const attrValues = p.match(/^\/api\/attributename\/(\d+)\/value$/);
if (attrValues) {
const attr = (catalog.attributes ?? []).find((a) => String(a.id) === attrValues[1]);
return json(res, 200, { values: attr?.values ?? [] });
}
if (p === '/api/propertyname') return json(res, 200, { propertynames: catalog.properties ?? [] });
if (p === '/api/supplier') return json(res, 200, { suppliers: parties.suppliers ?? [] });
const supplierArticles = p.match(/^\/api\/supplier\/(\d+)\/articles$/);
if (supplierArticles) return json(res, 200, { articles: [] });
if (p === '/api/entity') return json(res, 200, { entities: parties.entities ?? [] });
if (p === '/api/entity/next-number') return json(res, 200, { number: 'KD-001001' });
const entityMatch = p.match(/^\/api\/entity\/(\d+)$/);
if (entityMatch) {
const found = (parties.entities ?? []).find((e) => String(e.id) === entityMatch[1]);
if (!found && parties.entityDetail && String(parties.entityDetail.id) === entityMatch[1]) {
return json(res, 200, { entity: parties.entityDetail });
}
if (!found) return json(res, 404, { error: 'Entity not found' });
return json(res, 200, { entity: found });
}
const entityAddr = p.match(/^\/api\/entity\/(\d+)\/address$/);
if (entityAddr) return json(res, 200, { addresses: parties.entityAddresses ?? [] });
const entityContact = p.match(/^\/api\/entity\/(\d+)\/contact$/);
if (entityContact) return json(res, 200, { contacts: parties.entityContacts ?? [] });
if (p === '/api/customergroup') return json(res, 200, { customergroups: parties.customergroups ?? [] });
if (p === '/api/delivery') return json(res, 200, { deliveries: documents.deliveries ?? [] });
const deliveryMatch = p.match(/^\/api\/delivery\/(\d+)$/);
if (deliveryMatch) {
const found = (documents.deliveries ?? []).find((d) => String(d.id) === deliveryMatch[1]);
const detail = documents.deliveryDetail && String(documents.deliveryDetail.id) === deliveryMatch[1]
? documents.deliveryDetail
: null;
if (detail) return json(res, 200, { delivery: detail });
if (found && !detail) {
return json(res, 200, {
delivery: { ...found, lines: [], files: found.files ?? [] },
});
}
return json(res, 404, { error: 'Delivery not found' });
}
if (p === '/api/quote') return json(res, 200, { quotes: documents.quotes ?? [] });
const quoteMatch = p.match(/^\/api\/quote\/(\d+)$/);
if (quoteMatch) {
const found = (documents.quotes ?? []).find((q) => String(q.id) === quoteMatch[1]);
if (!found) return json(res, 404, { error: 'Quote not found' });
return json(res, 200, { quote: { ...found, lines: found.lines ?? [] } });
}
if (p === '/api/order') return json(res, 200, { orders: documents.orders ?? [] });
const orderMatch = p.match(/^\/api\/order\/(\d+)$/);
if (orderMatch) {
const found = (documents.orders ?? []).find((o) => String(o.id) === orderMatch[1]);
if (!found) return json(res, 404, { error: 'Order not found' });
return json(res, 200, { order: { ...found, lines: found.lines ?? [] } });
}
if (p === '/api/number') return json(res, 200, { numbers: documents.numbers ?? [] });
if (p === '/api/posterminal') return json(res, 200, { posterminals: misc.posterminals ?? [] });
if (p === '/api/printer') return json(res, 200, { printers: misc.printers ?? [] });
const printerById = p.match(/^\/api\/printer\/(\d+)$/);
if (printerById) {
const found = (misc.printers ?? []).find((x) => String(x.id) === printerById[1]);
if (!found) return json(res, 404, { error: 'Printer not found' });
return json(res, 200, { printer: found });
}
if (p === '/api/labeltemplate') return json(res, 200, { labeltemplates: misc.labeltemplates ?? [] });
const labelById = p.match(/^\/api\/labeltemplate\/(\d+)$/);
if (labelById) {
const found = (misc.labeltemplates ?? []).find((x) => String(x.id) === labelById[1]);
if (!found) return json(res, 404, { error: 'Label template not found' });
return json(res, 200, { labeltemplate: found });
}
if (p === '/api/company/setting') return json(res, 200, { settings: company.settings ?? [] });
const settingByKey = p.match(/^\/api\/company\/setting\/(.+)$/);
if (settingByKey) {
const found = (company.settings ?? []).find((s) => s.key === decodeURIComponent(settingByKey[1]));
if (!found) return json(res, 404, { error: 'Setting not found' });
return json(res, 200, { setting: found });
}
if (p === '/api/vfs') return json(res, 200, { files: misc.vfs ?? [] });
if (p === '/api/history') return json(res, 200, { history: misc.globalHistory ?? [], rows: misc.globalHistory ?? [] });
if (p === '/api/modelconversation') return json(res, 200, { conversations: [] });
const conversationMsgs = p.match(/^\/api\/modelconversation\/(\d+)\/message$/);
if (conversationMsgs) return json(res, 200, { messages: [] });
const ledgerSku = p.match(/^\/api\/ledger\/sku\/(.+)$/);
if (ledgerSku) return json(res, 200, { ledger: [] });
const ledgerMa = p.match(/^\/api\/ledger\/manifestarticle\/(\d+)$/);
if (ledgerMa) return json(res, 200, { ledger: [] });
if (p === '/api/ledger') return json(res, 200, { ledger: [] });
if (p === '/api/article/search') {
const q = (url.searchParams.get('target') ?? '').toLowerCase();
const all = Object.values(catalog.articlesByCategory ?? {}).flat();
const seen = new Map();
for (const a of all) seen.set(String(a.sku), a);
const rows = [...seen.values()].filter(
(a) => !q || String(a.sku).toLowerCase().includes(q) || String(a.name ?? '').toLowerCase().includes(q),
);
return json(res, 200, { articles: rows.slice(0, 50), total: rows.length });
}
return json(res, 404, { error: 'Not found' });
}
// ── Mutations: echo fixtures so the UI never crashes mid-run ──
const body = await readBody(req);
if (method === 'POST' && p === '/api/article') {
return json(res, 201, { article: { ...catalog.articleDetail, ...(body ?? {}) } });
}
const skuPatch = p.match(/^\/api\/article\/([^/]+)$/);
if (skuPatch && (method === 'PATCH' || method === 'PUT')) {
return json(res, 200, { article: { ...catalog.articleDetail, ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/article\/[^/]+(\/image\/.+)?$/)) {
res.writeHead(204);
return res.end();
}
if (p.match(/^\/api\/article\/[^/]+\/(prices|weight|brand|eans|categories|images\/order|supplier\/order|attributevalue|property|articlelist|optionslist|parent|children|print)$/) && ['PUT', 'PATCH', 'POST'].includes(method)) {
if (p.endsWith('/supplier/order')) return json(res, 200, { ids: body?.ids ?? [] });
if (p.endsWith('/images/order')) return json(res, 200, { fullHashes: body?.fullHashes ?? [] });
if (p.endsWith('/articlelist')) return json(res, 200, { items: body?.items ?? [] });
if (p.endsWith('/optionslist')) return json(res, 200, { options: body?.options ?? [] });
if (p.endsWith('/attributevalue')) return json(res, 200, { attributevalues: [] });
if (p.endsWith('/property')) return json(res, 200, { properties: [] });
return json(res, 200, { article: { ...catalog.articleDetail, ...(body ?? {}) } });
}
if (p.match(/^\/api\/article\/[^/]+\/supplier(\/\d+)?$/) && ['POST', 'PATCH'].includes(method)) {
return json(res, method === 'POST' ? 201 : 200, {
supplier: { id: 1, entityId: 1, ...(body ?? {}) },
});
}
if (method === 'DELETE' && p.match(/^\/api\/article\/[^/]+\/(supplier|property|children)\/.+$/)) {
return json(res, 200, { ok: true });
}
if (p === '/api/category' && method === 'POST') {
return json(res, 201, { category: { id: 999001, parentId: body?.parentId ?? 1, sort: 0, ...(body ?? {}) } });
}
if (p.match(/^\/api\/category\/\d+$/) && ['PATCH', 'PUT'].includes(method)) {
const id = Number(p.split('/').pop());
return json(res, 200, { category: { id, ...(body ?? {}) } });
}
if (p === '/api/category/order' && method === 'PUT') {
return json(res, 200, { categoryIds: body?.categoryIds ?? [] });
}
if (method === 'DELETE' && p.match(/^\/api\/category\/\d+(\/image)?$/)) {
return json(res, 200, { category: { id: Number(p.split('/')[3]) } });
}
if (p === '/api/brand' && method === 'PUT') {
return json(res, 200, { brand: { id: 999001, ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/brand\/[^/]+(\/image)?$/)) {
return json(res, 200, { brand: { id: 999001 } });
}
if (p === '/api/unit' && method === 'PUT') {
return json(res, 200, { unit: { ...(body ?? {}) } });
}
if (method === 'DELETE' && p.startsWith('/api/unit')) {
res.writeHead(204);
return res.end();
}
if (p === '/api/supplier' && method === 'POST') {
return json(res, 201, { supplier: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/supplier\/\d+$/) && method === 'PATCH') {
return json(res, 200, { supplier: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/(supplier|warehouse|printer|labeltemplate|customergroup|entity|delivery|quote|order|posterminal)\/[^/]+(\/(address|contact|category|all))?$/)) {
if (p.includes('/posterminal/') && (p.endsWith('/all') || p.includes('/category/'))) {
return json(res, 200, { id: Number(p.split('/')[3]), categoryIds: [] });
}
res.writeHead(204);
return res.end();
}
if (p === '/api/warehouse' && method === 'POST') {
return json(res, 201, { warehouse: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/warehouse\/\d+$/) && method === 'PATCH') {
return json(res, 200, { warehouse: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/warehouse/order' && method === 'PUT') {
return json(res, 200, { warehouseIds: body?.warehouseIds ?? [] });
}
if (p === '/api/stock/adjust' && method === 'POST') {
return json(res, 200, { ok: true });
}
if (p === '/api/delivery' && method === 'POST') {
return json(res, 201, { delivery: { ...(documents.deliveryDetail ?? {}), ...(body ?? {}) } });
}
if (p.match(/^\/api\/delivery\/\d+$/) && method === 'PATCH') {
return json(res, 200, { delivery: { ...(documents.deliveryDetail ?? {}), ...(body ?? {}) } });
}
if (p === '/api/quote' && method === 'POST') {
return json(res, 201, { quote: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/quote\/\d+$/) && method === 'PATCH') {
return json(res, 200, { quote: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/order' && method === 'POST') {
return json(res, 201, { order: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/order\/\d+$/) && method === 'PATCH') {
return json(res, 200, { order: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/entity' && method === 'POST') {
return json(res, 201, { entity: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/entity\/\d+$/) && method === 'PATCH') {
return json(res, 200, { entity: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p.match(/^\/api\/entity\/\d+\/(address|contact)$/) && method === 'POST') {
const kind = p.endsWith('address') ? 'address' : 'contact';
return json(res, 201, { [kind]: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/entity\/\d+\/(address|contact)\/\d+$/) && method === 'PATCH') {
const kind = p.includes('/address/') ? 'address' : 'contact';
return json(res, 200, { [kind]: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/customergroup' && method === 'POST') {
return json(res, 201, { customergroup: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/customergroup\/\d+$/) && method === 'PATCH') {
return json(res, 200, { customergroup: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/customergroup/order' && method === 'PUT') {
return json(res, 200, { customergroupIds: body?.customergroupIds ?? [] });
}
if (p === '/api/company' && method === 'POST') {
return json(res, 201, { company: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/company\/\d+$/) && method === 'PUT') {
return json(res, 200, { company: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p.match(/^\/api\/company\/setting\/.+$/) && method === 'PUT') {
return json(res, 200, { setting: { key: decodeURIComponent(p.split('/').pop()), ...(body ?? {}) } });
}
if (p.match(/^\/api\/number\/\d+$/) && method === 'PATCH') {
return json(res, 200, { number: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/posterminal' && method === 'PUT') {
return json(res, 200, { posterminal: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/posterminal\/\d+\/(all|category\/\d+)$/) && ['PUT', 'DELETE'].includes(method)) {
return json(res, 200, { id: Number(p.split('/')[3]), categoryIds: [] });
}
if (p === '/api/printer' && method === 'POST') {
return json(res, 201, { printer: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/printer\/\d+$/) && method === 'PATCH') {
return json(res, 200, { printer: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/printer/order' && method === 'PUT') {
return json(res, 200, { printerIds: body?.printerIds ?? [] });
}
if (p === '/api/labeltemplate' && method === 'POST') {
return json(res, 201, { labeltemplate: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/labeltemplate\/.+$/) && method === 'PATCH') {
return json(res, 200, { labeltemplate: { id: p.split('/').pop(), ...(body ?? {}) } });
}
if (p === '/api/ledger' && method === 'POST') {
return json(res, 201, { ledger: { id: 999001, ...(body ?? {}) } });
}
if (p === '/api/attributename' && method === 'POST') {
return json(res, 201, { attributename: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/attributename\/.+$/) && ['PATCH', 'PUT'].includes(method)) {
return json(res, 200, { attributename: { id: p.split('/').pop(), ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/(attributename|attributevalue|propertyname)\/.+$/)) {
return json(res, 200, { ok: true });
}
if (p === '/api/propertyname' && method === 'POST') {
return json(res, 201, { propertyname: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/propertyname\/.+$/) && ['PATCH', 'PUT'].includes(method)) {
return json(res, 200, { propertyname: { id: p.split('/').pop(), ...(body ?? {}) } });
}
if (p === '/api/modelconversation' && method === 'POST') {
return json(res, 201, { conversation: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/modelconversation\/\d+\/message$/) && method === 'POST') {
return json(res, 201, { message: { id: 999001, ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/modelconversation\/\d+$/)) {
res.writeHead(204);
return res.end();
}
if (p === '/api/vfs/upload' && method === 'POST') {
return json(res, 200, { files: [] });
}
if (p.startsWith('/api/vfs/') && method === 'PUT') {
return json(res, 200, { ok: true });
}
if (method === 'DELETE' && p.startsWith('/api/vfs/')) {
res.writeHead(204);
return res.end();
}
return json(res, 404, { error: 'Not found' });
});
server.listen(PORT, () => {
console.log(`[dummy-backend] fixtures on http://localhost:${PORT} (${Object.keys(catalog.articlesByCategory ?? {}).length} categories)`);
});

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 940 B

After

Width:  |  Height:  |  Size: 940 B

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 891 B

After

Width:  |  Height:  |  Size: 891 B

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Some files were not shown because too many files have changed in this diff Show More