u
3
.gitignore
vendored
@@ -5,3 +5,6 @@ dist-ssr
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
certs/
|
certs/
|
||||||
.dashfront-token
|
.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",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:https": "VITE_HTTPS=1 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": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@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).
|
* Auth: session cookie. Browser-cacheable (private, immutable, ETag).
|
||||||
*/
|
*/
|
||||||
export const thumb = {
|
export const thumb = {
|
||||||
@@ -2021,6 +2021,34 @@ export const vfs = {
|
|||||||
}
|
}
|
||||||
return res.json();
|
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 {
|
export default {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const initialState = {
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
vfsWidth: 480,
|
vfsWidth: 480,
|
||||||
newFileDialogOpen: false,
|
newFileDialogOpen: false,
|
||||||
|
newFileDefaultFolder: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
let vfsAutomat;
|
let vfsAutomat;
|
||||||
@@ -105,8 +106,8 @@ const actions = {
|
|||||||
vfsAutomat.setState({ searchQuery });
|
vfsAutomat.setState({ searchQuery });
|
||||||
},
|
},
|
||||||
|
|
||||||
setNewFileDialogOpen(newFileDialogOpen) {
|
setNewFileDialogOpen(newFileDialogOpen, newFileDefaultFolder = '') {
|
||||||
vfsAutomat.setState({ newFileDialogOpen });
|
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.
|
* Upload one or more files to VFS.
|
||||||
* @param {File[]|FileList|File} files
|
* @param {File[]|FileList|File} files
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded
|
|||||||
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
|
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
|
||||||
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
|
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
|
||||||
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
|
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 { setting as settingApi } from '../../api';
|
||||||
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
||||||
@@ -627,6 +630,17 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
this.setState({ modelreasoning: current });
|
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) => {
|
handleModelslugChange = (e) => {
|
||||||
this.setState({ modelslug: e.target.value });
|
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 }} />
|
<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 */}
|
{/* modelslug input */}
|
||||||
<InputBase
|
<InputBase
|
||||||
value={modelslug || ''}
|
value={modelslug || ''}
|
||||||
@@ -1167,6 +1254,8 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
<Box
|
<Box
|
||||||
component="form"
|
component="form"
|
||||||
onSubmit={this.handleSend}
|
onSubmit={this.handleSend}
|
||||||
|
noValidate
|
||||||
|
autoComplete="off"
|
||||||
onDragEnter={this.handleDragEnter}
|
onDragEnter={this.handleDragEnter}
|
||||||
onDragOver={this.handleDragOver}
|
onDragOver={this.handleDragOver}
|
||||||
onDragLeave={this.handleDragLeave}
|
onDragLeave={this.handleDragLeave}
|
||||||
@@ -1444,6 +1533,12 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
onFocus={() => this.setState({ isInputFocused: true })}
|
onFocus={() => this.setState({ isInputFocused: true })}
|
||||||
onBlur={() => this.setState({ isInputFocused: false })}
|
onBlur={() => this.setState({ isInputFocused: false })}
|
||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
|
inputProps={{
|
||||||
|
spellCheck: false,
|
||||||
|
autoCorrect: 'off',
|
||||||
|
autoCapitalize: 'off',
|
||||||
|
autoComplete: 'off',
|
||||||
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
pendingAsk
|
pendingAsk
|
||||||
? 'Bitte Frage oben beantworten…'
|
? 'Bitte Frage oben beantworten…'
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
isUploading: s.isUploading,
|
isUploading: s.isUploading,
|
||||||
searchQuery: s.searchQuery,
|
searchQuery: s.searchQuery,
|
||||||
newFileDialogOpen: s.newFileDialogOpen,
|
newFileDialogOpen: s.newFileDialogOpen,
|
||||||
|
newFileDefaultFolder: s.newFileDefaultFolder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
isUploading: state.isUploading,
|
isUploading: state.isUploading,
|
||||||
searchQuery: state.searchQuery,
|
searchQuery: state.searchQuery,
|
||||||
newFileDialogOpen: state.newFileDialogOpen,
|
newFileDialogOpen: state.newFileDialogOpen,
|
||||||
|
newFileDefaultFolder: state.newFileDefaultFolder,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (this.state.files.length === 0 && !this.state.loadingFiles) {
|
if (this.state.files.length === 0 && !this.state.loadingFiles) {
|
||||||
@@ -180,10 +182,12 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
|
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
|
||||||
onRefresh={() => vfsAutomat.actions.loadFiles()}
|
onRefresh={() => vfsAutomat.actions.loadFiles()}
|
||||||
onOpenNewDialog={() => vfsAutomat.actions.setNewFileDialogOpen(true)}
|
onOpenNewDialog={(parentDir) => vfsAutomat.actions.setNewFileDialogOpen(true, parentDir)}
|
||||||
onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)}
|
onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)}
|
||||||
onDownload={(name) => vfsAutomat.actions.downloadFile(name)}
|
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)}
|
onUpload={(filesToUpload) => vfsAutomat.actions.uploadFiles(filesToUpload)}
|
||||||
onMentionInChat={this.handleMentionInChat}
|
onMentionInChat={this.handleMentionInChat}
|
||||||
/>
|
/>
|
||||||
@@ -194,6 +198,7 @@ export default class VfsEditorPanel extends PureComponent {
|
|||||||
{/* New File Dialog */}
|
{/* New File Dialog */}
|
||||||
<VfsNewFileDialog
|
<VfsNewFileDialog
|
||||||
open={newFileDialogOpen}
|
open={newFileDialogOpen}
|
||||||
|
defaultFolder={this.state.newFileDefaultFolder}
|
||||||
onClose={() => vfsAutomat.actions.setNewFileDialogOpen(false)}
|
onClose={() => vfsAutomat.actions.setNewFileDialogOpen(false)}
|
||||||
onCreate={(name) => vfsAutomat.actions.createFile(name)}
|
onCreate={(name) => vfsAutomat.actions.createFile(name)}
|
||||||
isCreating={isSaving}
|
isCreating={isSaving}
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownR
|
|||||||
import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded';
|
import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded';
|
||||||
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||||
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
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 VfsFileIcon from './VfsFileIcon';
|
||||||
import { formatVfsSize, formatVfsDate } from './vfsUtils';
|
import { formatVfsSize, formatVfsDate } from './vfsUtils';
|
||||||
|
import VfsNewFolderDialog from './VfsNewFolderDialog';
|
||||||
|
import VfsRenameDialog from './VfsRenameDialog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all ancestor directory paths for a file or folder path.
|
* Returns all ancestor directory paths for a file or folder path.
|
||||||
@@ -119,6 +123,14 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
this.state = {
|
this.state = {
|
||||||
menuAnchor: null,
|
menuAnchor: null,
|
||||||
menuFile: null,
|
menuFile: null,
|
||||||
|
folderMenuAnchor: null,
|
||||||
|
folderMenuNode: null,
|
||||||
|
renameDialogOpen: false,
|
||||||
|
renameItem: null,
|
||||||
|
isRenaming: false,
|
||||||
|
newFolderDialogOpen: false,
|
||||||
|
targetParentFolder: '',
|
||||||
|
isCreatingFolder: false,
|
||||||
deleteConfirmFile: null,
|
deleteConfirmFile: null,
|
||||||
isDraggingOver: false,
|
isDraggingOver: false,
|
||||||
expandedFolders: initialExpanded,
|
expandedFolders: initialExpanded,
|
||||||
@@ -168,6 +180,58 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
this.setState({ menuAnchor: null, menuFile: null });
|
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 = () => {
|
handleTriggerUpload = () => {
|
||||||
this.fileInputRef.current?.click();
|
this.fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
@@ -313,6 +377,27 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
>
|
>
|
||||||
{folder.totalFileCount}
|
{folder.totalFileCount}
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{/* Folder Children (Directories FIRST, then files) */}
|
{/* Folder Children (Directories FIRST, then files) */}
|
||||||
@@ -471,7 +556,19 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
} = this.props;
|
} = 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;
|
const isText = menuFile?.isText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -590,10 +687,20 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
<CloudUploadRoundedIcon fontSize="small" />
|
<CloudUploadRoundedIcon fontSize="small" />
|
||||||
</IconButton>
|
</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) */}
|
{/* New File Icon Button (no word) */}
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={onOpenNewDialog}
|
onClick={() => onOpenNewDialog?.('')}
|
||||||
aria-label="Neue Datei anlegen"
|
aria-label="Neue Datei anlegen"
|
||||||
sx={{ p: 0.75 }}
|
sx={{ p: 0.75 }}
|
||||||
>
|
>
|
||||||
@@ -661,6 +768,25 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
<ListItemText primary="Pfad kopieren" />
|
<ListItemText primary="Pfad kopieren" />
|
||||||
</MenuItem>
|
</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 && (
|
{this.props.onMentionInChat && (
|
||||||
<MenuItem onClick={() => this.handleMentionInChat(menuFile)}>
|
<MenuItem onClick={() => this.handleMentionInChat(menuFile)}>
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
@@ -685,6 +811,99 @@ export default class VfsFileExplorer extends PureComponent {
|
|||||||
<ListItemText primary="Datei löschen" />
|
<ListItemText primary="Datei löschen" />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
|||||||
export default class VfsNewFileDialog extends PureComponent {
|
export default class VfsNewFileDialog extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
const folder = props.defaultFolder ? `${String(props.defaultFolder).replace(/^\/+/, '').replace(/\/+$/, '')}/` : '';
|
||||||
this.state = {
|
this.state = {
|
||||||
filename: '',
|
filename: folder,
|
||||||
error: '',
|
error: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (!prevProps.open && this.props.open) {
|
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 |