Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions backend/tests/test_embedded_datasource_navigation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Offline navigation regressions using actual TS handlers and Vue Router.

Requires Node and the existing frontend npm dependencies; no server or LLM calls.
"""

import shutil
import subprocess
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parents[2]

SCRIPT = r"""
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const frontend = path.join(process.cwd(), 'frontend');
const ts = require(path.join(frontend, 'node_modules/typescript'));
const {createRouter, createMemoryHistory} = require(path.join(frontend, 'node_modules/vue-router'));
const source = file => fs.readFileSync(path.join(frontend, 'src', file), 'utf8');
function declaration(file, name) {
const raw = source(file).replace(/^.*?<script[^>]*>/s, '').split('</script>')[0];
const ast = ts.createSourceFile(file+'.ts', raw, ts.ScriptTarget.Latest, true);
const node = ast.statements.find(n => n.name?.getText(ast) === name ||
n.declarationList?.declarations.some(d => d.name.getText(ast) === name));
assert.ok(node, `missing declaration ${name}`);
return node.getText(ast).replace(/^export /, '');
}
function evaluate(code, bindings, name) {
const js = ts.transpileModule(code, {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText;
return new Function(...Object.keys(bindings), js+'; return '+name)(...Object.values(bindings));
}
async function navigation({embedded=false, token='', uid=42, model=true, target, admin=false, spaceAdmin=true}) {
const router = createRouter({history:createMemoryHistory(),routes:[
'/embeddedCommon','/embeddedPage','/assistant','/chat/index','/login','/admin-login',
'/401','/system/model','/set/member','/ds/index',
{path:'/chat',redirect:'/chat/index'},
].map(route=>typeof route === 'string' ? {path:route,component:{}} : route)});
const trace=[];
router.beforeEach(to=> {trace.push(to.fullPath); return trace.length>8 ? false:undefined;});
const watchSource = source('router/watch.ts').replace(/^import .*$/gm,'')
.replace(/export const /g,'const ').split('const loadXpackStatic =')[0];
const userStore={getUid:uid,isSpaceAdmin:spaceAdmin,isAdmin:admin,info:async()=>{userStore.getUid=42;}};
evaluate(watchSource,{
useAppearanceStoreWithOut:()=>({setAppearance:async()=>{}}),useUserStore:()=>userStore,
useCache:()=>({wsCache:{get:()=>token}}),LicenseGenerator:{generateRouters(){}},
loadXpackStatic:async()=>{},generateDynamicRouters:()=>{},
toLoginPage:evaluate(declaration('utils/utils.ts','toLoginPage'),{},'toLoginPage'),
},'watchRouter')(router);
await router.push('/embeddedCommon?id=fixture&lang=en');
let pending;
const events=[];
let dialogs=0;
const handle=evaluate(declaration('views/ds/Datasource.vue','handleQuestion'),{
router:{push:to=>(pending=router.push(to))},
chatApi:{checkLLMModel:async()=>{if(!model) throw new Error('unavailable');}},
props:{pageEmbedded:embedded},emits:(...args)=>events.push(args),userStore,
t:key=>key,ElMessageBox:{confirm:()=>{dialogs++;}},console:{error(){}},
},'handleQuestion');
if (target) await router.push(target);
else {await handle('9007199254740993'); await pending;}
assert.ok(trace.length<=8, 'navigation must terminate: '+JSON.stringify(trace));
return {route:router.currentRoute.value,events,dialogs};
}
(async()=>{
const mode=process.argv[1];
if(mode==='embedded') {
for (const token of ['', 'ordinary-fixture']) {
const result=await navigation({embedded:true,token});
assert.equal(result.route.fullPath,'/embeddedCommon?id=fixture&lang=en');
assert.deepEqual(result.events,[['start-chat','9007199254740993']]);
}
const result=await navigation({embedded:true,model:false});
assert.deepEqual(result.events,[]);
assert.equal(result.dialogs,1);
assert.equal(result.route.path,'/embeddedCommon');
} else if(mode==='authentication') {
for(const uid of [42,null]) {
const result=await navigation({uid});
assert.equal(result.route.path,'/login');
assert.equal(result.route.query.redirect,'/chat/index?start_chat=9007199254740993');
}
const normal=await navigation({token:'ordinary-fixture'});
assert.equal(normal.route.fullPath,'/chat/index?start_chat=9007199254740993');
assert.deepEqual(normal.events,[]);
for (const target of ['/login?redirect=/chat/index','/admin-login','/assistant','/embeddedPage','/401']) {
const result=await navigation({target});
assert.equal(result.route.path,target.split('?')[0]);
}
const loggedIn=await navigation({token:'ordinary-fixture',target:'/login?redirect=/chat/index'});
assert.equal(loggedIn.route.path,'/chat/index');
for(const target of ['/system/model','/set/member','/ds/index']) {
const rejected=await navigation({token:'ordinary-fixture',target,spaceAdmin:false});
assert.equal(rejected.route.path,'/chat/index');
const allowed=await navigation({token:'ordinary-fixture',target,admin:true});
assert.equal(allowed.route.path,target);
}
} else if(mode==='component') {
const vue = require(path.join(frontend, 'node_modules/vue'));
const {parse,compileScript} = require(path.join(frontend, 'node_modules/@vue/compiler-sfc'));
const {descriptor} = parse(source('views/embedded/common.vue'));
const compiled = compileScript(descriptor,{id:'embedded-test',inlineTemplate:true});
const js = ts.transpileModule(compiled.content,{compilerOptions:{
target:ts.ScriptTarget.ES2020,module:ts.ModuleKind.CommonJS,
}}).outputText;
for(const permitted of [true,false]) {
const listeners = new Map();
const assistant = {setPageEmbedded(){},setType(){},setId(){},setAssistant(){},setToken(){}};
const user = vue.reactive({isAdmin:false,isSpaceAdmin:permitted,info:async()=>{},setLanguage(){}});
const ds = {props:['pageEmbedded'],emits:['start-chat'],setup:(props,{emit})=>()=>
vue.h('start-button',{onClick:()=>emit('start-chat','9007199254740993')})};
const chat = {props:['pageEmbedded','startChatDsId'],setup:props=>()=>vue.h('chat-view',{
datasource:props.startChatDsId,embedded:props.pageEmbedded,
})};
const imports = {
'vue':vue,'vue-router':{useRoute:()=>({query:{id:'fixture',lang:'en'}})},
'vue-i18n':{useI18n:()=>({t:key=>key})},
'@/views/ds/Datasource.vue':{default:ds},'@/views/chat/index.vue':{default:chat},
'@/views/error/index.vue':{default:{render:()=>vue.h('denied-view')}},
'@/stores/assistant':{useAssistantStore:()=>assistant},'@/stores/user':{useUserStore:()=>user},
};
const exports = {};
new Function('require','exports','window','setTimeout',js)(name=>{
assert.ok(imports[name],`unexpected import ${name}`); return imports[name];
},exports,{
addEventListener:(name,fn)=>listeners.set(name,fn),
removeEventListener:name=>listeners.delete(name),parent:{postMessage(){}},
},fn=>fn());
const node = type=>({type,children:[],props:{},style:{}});
const renderer = vue.createRenderer({
createElement:node,createText:()=>node('text'),createComment:()=>node('comment'),
setText(){},setElementText(){},patchProp:(el,key,old,value)=>{el.props[key]=value;},
insert(el,parent,anchor){el.parent=parent;const i=parent.children.indexOf(anchor);
if(i<0) parent.children.push(el); else parent.children.splice(i,0,el);},
remove(el){el.parent.children.splice(el.parent.children.indexOf(el),1);},
parentNode:el=>el.parent,nextSibling:el=>el.parent?.children[el.parent.children.indexOf(el)+1],
});
const root=node('root');
const app=renderer.createApp(exports.default);
app.component('el-button',{setup:(_,{attrs,slots})=>()=>vue.h('back-button',attrs,slots.default?.())});
app.directive('loading',{});
app.mount(root);
const find=(type,el=root)=>el.type===type ? el : el.children.map(child=>find(type,child)).find(Boolean);
assert.equal(find('start-button'),undefined,'wait for embedded certificate');
await listeners.get('message')({data:{eventName:'sqlbot_embedded_event',messageId:'fixture',
busiFlag:'ds',busi:'certificate',type:4,certificate:'synthetic-fixture'}});
await vue.nextTick();
if(permitted) {
for(let i=0;i<2;i++) {
find('start-button').props.onClick(); await vue.nextTick();
assert.ok(find('chat-view'), 'start-chat must open the embedded chat view');
assert.deepEqual(find('chat-view').props,{datasource:'9007199254740993',embedded:true});
assert.equal(find('start-button').parent.style.display,'none');
find('back-button').props.onClick(); await vue.nextTick();
assert.equal(find('chat-view'),undefined);
assert.ok(find('start-button'));
assert.notEqual(find('start-button').parent.style.display,'none');
}
} else {
assert.ok(find('denied-view'));
assert.equal(find('start-button'),undefined);
assert.equal(find('chat-view'),undefined);
}
app.unmount();
assert.equal(listeners.size,0,'release message listener');
}
} else if(mode==='chat-context') {
for(const pageEmbedded of [true,false]) {
const calls=[];
const replacements=[];
evaluate(declaration('views/chat/index.vue','jumpCreatChat'),{
props:{pageEmbedded,startChatDsId:'9007199254740993'},nextTick:fn=>fn(),
hiddenChatCreatorRef:{value:{createChat:id=>calls.push(id)}},
window:{location:{hash:'#/embeddedCommon?id=fixture&lang=en'}},
history:{replaceState:(...args)=>replacements.push(args)},
},'jumpCreatChat')();
assert.deepEqual(calls,['9007199254740993']);
if(pageEmbedded) assert.deepEqual(replacements,[], 'preserve embedded handshake id and language');
}
}
})().catch(error=>{console.error(error);process.exitCode=1;});
"""


@pytest.mark.parametrize(
"mode", ["embedded", "authentication", "chat-context", "component"]
)
def test_embedded_navigation(mode):
if (
not shutil.which("node")
or not (ROOT / "frontend/node_modules/typescript").exists()
):
pytest.skip("Node and frontend npm dependencies are required")
result = subprocess.run(
["node", "-e", SCRIPT, mode],
cwd=ROOT,
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 0, result.stdout + result.stderr
4 changes: 2 additions & 2 deletions frontend/src/router/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ export const watchRouter = (router: Router) => {
await loadXpackStatic()
await appearanceStore.setAppearance()
LicenseGenerator.generateRouters(router)
if (to.path.startsWith('/login') && userStore.getUid) {
const token = wsCache.get('user.token')
if (to.path.startsWith('/login') && token && userStore.getUid) {
next(to?.query?.redirect || '/')
return
}
if (assistantWhiteList.includes(to.path)) {
next()
return
}
const token = wsCache.get('user.token')
if (whiteList.includes(to.path)) {
next()
return
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/views/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ import QuickQuestion from '@/views/chat/QuickQuestion.vue'
import { useChatConfigStore } from '@/stores/chatConfig.ts'
const userStore = useUserStore()
const props = defineProps<{
startChatDsId?: number
startChatDsId?: number | string
welcomeDesc?: string
logoAssistant?: string
welcome?: string
Expand Down Expand Up @@ -1126,8 +1126,10 @@ function jumpCreatChat() {
nextTick(() => {
hiddenChatCreatorRef.value?.createChat(_id)
})
const newUrl = window.location.hash.replace(/\?.*$/, '')
history.replaceState({}, '', newUrl)
if (!props.pageEmbedded) {
const newUrl = window.location.hash.replace(/\?.*$/, '')
history.replaceState({}, '', newUrl)
}
}
}
function checkAfterVisible() {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/views/ds/Datasource.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { chatApi } from '@/api/chat'
import RecommendedProblemConfigDialog from '@/views/ds/RecommendedProblemConfigDialog.vue'
import { highlightKeyword } from '@/utils/xss'
const userStore = useUserStore()
const props = defineProps<{ pageEmbedded?: boolean }>()
const emits = defineEmits<{ 'start-chat': [id: string] }>()
const recommendedProblemConfigRef = ref()

export interface Datasource {
Expand Down Expand Up @@ -111,6 +113,10 @@ const handleQuestion = async (id: string) => {
})
return
}
if (props.pageEmbedded) {
emits('start-chat', id)
return
}
router.push({
path: '/chat/index',
query: {
Expand Down
36 changes: 32 additions & 4 deletions frontend/src/views/embedded/common.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
<template>
<div v-loading="divLoading" class="sqlbot--embedded-page">
<ds-component
<div
v-if="!loading && isWsAdmin && busiFlag === 'ds'"
ref="dsRef"
:page-embedded="true"
/>
v-show="!startChatDsId"
class="embedded-datasource"
>
<ds-component ref="dsRef" :page-embedded="true" @start-chat="startChatDsId = $event" />
</div>
<div v-if="!loading && isWsAdmin && busiFlag === 'ds' && startChatDsId" class="embedded-chat">
<div class="embedded-chat-header">
<el-button text @click="startChatDsId = undefined">{{ t('common.back') }}</el-button>
</div>
<chat-component :start-chat-ds-id="startChatDsId" :page-embedded="true" />
</div>
<page-401
v-if="!loading && !isWsAdmin && busiFlag === 'ds'"
:title="t('login.permission_invalid')"
Expand All @@ -13,6 +21,7 @@
</template>
<script setup lang="ts">
import DsComponent from '@/views/ds/Datasource.vue'
import ChatComponent from '@/views/chat/index.vue'
import Page401 from '@/views/error/index.vue'
import { computed, nextTick, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
Expand All @@ -29,6 +38,7 @@ const loading = ref(true)
const divLoading = ref(true)
const eventName = 'sqlbot_embedded_event'
const busiFlag = ref('ds')
const startChatDsId = ref<string>()

const setParamLanguage = () => {
const lang = route.query.lang
Expand Down Expand Up @@ -122,4 +132,22 @@ onBeforeUnmount(() => {
position: relative;
background: #fff;
}
.embedded-datasource,
.embedded-chat {
height: 100%;
}
.embedded-chat {
display: flex;
flex-direction: column;

.embedded-chat-header {
padding: 8px 16px;
border-bottom: 1px solid var(--ed-border-color);
}

:deep(.chat-container) {
flex: 1;
min-height: 0;
}
}
</style>
Loading