u
3
.gitignore
vendored
@@ -5,3 +5,6 @@ dist-ssr
|
||||
.DS_Store
|
||||
certs/
|
||||
.dashfront-token
|
||||
# Visual testing rig: generated screenshots (reference is committed, current/diff are not)
|
||||
test/screenshots/current/
|
||||
test/screenshots/diff/
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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…'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
105
src/components/vfs/VfsNewFolderDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
107
src/components/vfs/VfsRenameDialog.jsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)`);
|
||||
});
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 940 B After Width: | Height: | Size: 940 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 891 B After Width: | Height: | Size: 891 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |