feat(12.1-03): align cloud browser contract — kind, provider_item_id, server freshness
- CloudFolderView: folders/files classified by kind=folder/file (remove is_dir) - CloudFolderView: navigateTo uses item.provider_item_id for route param (not item.id) - CloudFolderView: handleBreadcrumbNavigate uses named route; opaque refs intact - CloudFolderView: setBrowseState maps server freshness.refresh_state verbatim - CloudFolderView: refreshedAt = server last_refreshed_at (not new Date()) - CloudFolderView: breadcrumb lineage built explicitly from visited nodes - CloudProviderTreeItem: loadChildren filters by kind=folder (not is_dir) - CloudFolderTreeItem: expandable = kind===folder; icon = kind===folder - CloudFolderTreeItem: navigate() uses provider_item_id (not folder.id) - CloudFolderTreeItem: loadChildren uses provider_item_id as parent_ref - CloudFolderTreeItem: isActive checks provider_item_id first All 82 focused tests pass; full suite: 369/369
This commit is contained in:
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<TreeItem
|
<TreeItem
|
||||||
:label="folder.name"
|
:label="folder.name"
|
||||||
:expandable="folder.is_dir"
|
:expandable="folder.kind === 'folder'"
|
||||||
:load-children="loadChildren"
|
:load-children="loadChildren"
|
||||||
:depth="depth"
|
:depth="depth"
|
||||||
:is-active="isActive"
|
:is-active="isActive"
|
||||||
@select="navigate"
|
@select="navigate"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<AppIcon v-if="folder.is_dir" name="folder" class="w-4 h-4 shrink-0 text-gray-400" />
|
<AppIcon v-if="folder.kind === 'folder'" name="folder" class="w-4 h-4 shrink-0 text-gray-400" />
|
||||||
<AppIcon v-else name="fileDoc" class="w-4 h-4 shrink-0 text-gray-400" />
|
<AppIcon v-else name="fileDoc" class="w-4 h-4 shrink-0 text-gray-400" />
|
||||||
</template>
|
</template>
|
||||||
<template #children="{ children }">
|
<template #children="{ children }">
|
||||||
@@ -39,18 +39,40 @@ const props = defineProps({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
/** Active when the current route points to this exact folder. */
|
/**
|
||||||
|
* Active when the current route points to this exact folder.
|
||||||
|
* Uses provider_item_id for comparison — the opaque provider reference
|
||||||
|
* is what appears in the route param after normalization.
|
||||||
|
*/
|
||||||
const isActive = computed(() => {
|
const isActive = computed(() => {
|
||||||
|
const provRef = props.folder.provider_item_id
|
||||||
|
if (provRef !== undefined && provRef !== null) {
|
||||||
|
return route.path === `/cloud/${props.connectionId}/${provRef}`
|
||||||
|
}
|
||||||
|
// Fallback for legacy items that may only have id
|
||||||
return route.path === `/cloud/${props.connectionId}/${props.folder.id}`
|
return route.path === `/cloud/${props.connectionId}/${props.folder.id}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load nested children using provider_item_id as the parent_ref.
|
||||||
|
* provider_item_id is the opaque provider reference (Drive/OneDrive item IDs,
|
||||||
|
* WebDAV paths, etc.) — never use the DocuVault stable id for provider navigation.
|
||||||
|
* Classify results by kind, not is_dir.
|
||||||
|
*/
|
||||||
async function loadChildren() {
|
async function loadChildren() {
|
||||||
// Browse by connection UUID + provider item/path reference — never provider slug
|
// Use provider_item_id as the parent ref, falling back to id for legacy items
|
||||||
const data = await api.getCloudFoldersByConnectionId(props.connectionId, props.folder.id)
|
const parentRef = props.folder.provider_item_id ?? props.folder.id
|
||||||
return (data.items ?? []).filter(i => i.is_dir)
|
const data = await api.getCloudFoldersByConnectionId(props.connectionId, parentRef)
|
||||||
|
return (data.items ?? []).filter(i => i.kind === 'folder')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate using provider_item_id as the opaque route parameter.
|
||||||
|
* Reserved characters (/, ?, #, %, spaces, Unicode) are not decoded or split —
|
||||||
|
* Vue Router / API query serializers handle encoding.
|
||||||
|
*/
|
||||||
function navigate() {
|
function navigate() {
|
||||||
router.push(`/cloud/${props.connectionId}/${props.folder.id}`)
|
const provRef = props.folder.provider_item_id ?? props.folder.id
|
||||||
|
router.push(`/cloud/${props.connectionId}/${provRef}`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -53,10 +53,15 @@ const isActive = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load root-level folders using kind classification (not is_dir).
|
||||||
|
* Uses connection UUID, never provider slug.
|
||||||
|
* Root listing uses empty string as the sentinel for parent_ref=null.
|
||||||
|
*/
|
||||||
async function loadChildren() {
|
async function loadChildren() {
|
||||||
// Use connection UUID, never provider slug (UAT gap: 422 from UUID endpoint if slug passed)
|
|
||||||
const data = await api.getCloudFoldersByConnectionId(props.connection.id, '')
|
const data = await api.getCloudFoldersByConnectionId(props.connection.id, '')
|
||||||
return (data.items ?? []).filter(i => i.is_dir)
|
// Classify by kind — the normalized CloudItemOut schema has no is_dir field
|
||||||
|
return (data.items ?? []).filter(i => i.kind === 'folder')
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToRoot() {
|
function navigateToRoot() {
|
||||||
|
|||||||
@@ -43,8 +43,13 @@ const uploadQueue = ref([])
|
|||||||
const connectionId = computed(() => route.params.connectionId)
|
const connectionId = computed(() => route.params.connectionId)
|
||||||
const folderId = computed(() => route.params.folderId)
|
const folderId = computed(() => route.params.folderId)
|
||||||
|
|
||||||
const folders = computed(() => items.value.filter(i => i.is_dir))
|
/**
|
||||||
const files = computed(() => items.value.filter(i => !i.is_dir))
|
* Classify items by kind (canonical API field).
|
||||||
|
* The API returns CloudItemOut with kind="folder"|"file".
|
||||||
|
* Never use is_dir — that field is absent from the normalized schema.
|
||||||
|
*/
|
||||||
|
const folders = computed(() => items.value.filter(i => i.kind === 'folder'))
|
||||||
|
const files = computed(() => items.value.filter(i => i.kind === 'file'))
|
||||||
|
|
||||||
const connectionRoot = computed(() => {
|
const connectionRoot = computed(() => {
|
||||||
const conn = cloudStore.connections.find(c => c.id === connectionId.value)
|
const conn = cloudStore.connections.find(c => c.id === connectionId.value)
|
||||||
@@ -56,19 +61,67 @@ const connectionRoot = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumb = computed(() => {
|
/**
|
||||||
if (!folderId.value || folderId.value === 'root') return []
|
* Session-only breadcrumb lineage.
|
||||||
const parts = folderId.value.replace(/\/$/, '').split('/')
|
* Maintained as an explicit list of { name, providerItemId } visited nodes.
|
||||||
return parts.map((seg, idx) => ({
|
* Never reconstructed by splitting provider_item_id — Drive/OneDrive/WebDAV IDs are opaque.
|
||||||
id: parts.slice(0, idx + 1).join('/'),
|
*/
|
||||||
name: seg,
|
const breadcrumbLineage = ref([])
|
||||||
}))
|
|
||||||
})
|
const breadcrumb = computed(() => breadcrumbLineage.value)
|
||||||
|
|
||||||
const mappedBreadcrumb = computed(() =>
|
const mappedBreadcrumb = computed(() =>
|
||||||
breadcrumb.value.map(f => ({ id: f.id, label: f.name }))
|
breadcrumb.value.map(f => ({ id: f.providerItemId, label: f.name }))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a cloud folder using provider_item_id as the opaque provider reference.
|
||||||
|
* The DocuVault stable id (item.id) is kept for metadata identity only —
|
||||||
|
* never sent as a route param or to the provider adapter.
|
||||||
|
*
|
||||||
|
* Breadcrumb lineage is grown explicitly by appending a {name, providerItemId} node.
|
||||||
|
* Reserved characters (/, ?, #, %, spaces, Unicode) are not decoded or split.
|
||||||
|
*/
|
||||||
|
function navigateTo(item) {
|
||||||
|
// Grow breadcrumb lineage from current folderId
|
||||||
|
const currentRef = folderId.value && folderId.value !== 'root' ? folderId.value : null
|
||||||
|
// Find existing lineage position or append new node
|
||||||
|
const existing = breadcrumbLineage.value.findIndex(n => n.providerItemId === currentRef)
|
||||||
|
if (currentRef === null) {
|
||||||
|
// At root — start fresh lineage
|
||||||
|
breadcrumbLineage.value = []
|
||||||
|
} else if (existing === -1) {
|
||||||
|
// Not in lineage yet — this means we're navigating deeper from current position
|
||||||
|
// (lineage was built from previous navigations or restored from session)
|
||||||
|
}
|
||||||
|
// Append the folder we're navigating INTO
|
||||||
|
breadcrumbLineage.value = [
|
||||||
|
...breadcrumbLineage.value,
|
||||||
|
{ name: item.name, providerItemId: item.provider_item_id },
|
||||||
|
]
|
||||||
|
// Navigate using provider_item_id — opaque, never decoded or split
|
||||||
|
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: item.provider_item_id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle breadcrumb navigation.
|
||||||
|
* id is a providerItemId from the lineage — null means root.
|
||||||
|
* Opaque references are passed intact; never split or decoded.
|
||||||
|
*/
|
||||||
|
function handleBreadcrumbNavigate(id) {
|
||||||
|
if (id == null) {
|
||||||
|
breadcrumbLineage.value = []
|
||||||
|
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: 'root' } })
|
||||||
|
} else {
|
||||||
|
// Trim breadcrumb lineage to the clicked node
|
||||||
|
const idx = breadcrumbLineage.value.findIndex(n => n.providerItemId === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
breadcrumbLineage.value = breadcrumbLineage.value.slice(0, idx + 1)
|
||||||
|
}
|
||||||
|
router.push({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: id } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -79,11 +132,17 @@ async function load() {
|
|||||||
folderId.value ?? 'root'
|
folderId.value ?? 'root'
|
||||||
)
|
)
|
||||||
items.value = data.items ?? []
|
items.value = data.items ?? []
|
||||||
|
/**
|
||||||
|
* Map server freshness state verbatim — never infer fresh from HTTP 200.
|
||||||
|
* A successful cached browse response is not proof of provider synchronization.
|
||||||
|
* last_refreshed_at comes from the server; never use new Date() as evidence.
|
||||||
|
*/
|
||||||
|
const freshness = data.freshness ?? {}
|
||||||
cloudStore.setBrowseState({
|
cloudStore.setBrowseState({
|
||||||
items: items.value,
|
items: items.value,
|
||||||
caps: data.capabilities ?? null,
|
caps: data.capabilities ?? null,
|
||||||
freshness: 'fresh',
|
freshness: freshness.refresh_state ?? 'warning',
|
||||||
refreshedAt: new Date().toISOString(),
|
refreshedAt: freshness.last_refreshed_at ?? null,
|
||||||
byteAvail: data.byte_availability ?? null,
|
byteAvail: data.byte_availability ?? null,
|
||||||
err: null,
|
err: null,
|
||||||
})
|
})
|
||||||
@@ -98,6 +157,7 @@ async function load() {
|
|||||||
} else {
|
} else {
|
||||||
error.value = msg || 'Failed to load folder contents.'
|
error.value = msg || 'Failed to load folder contents.'
|
||||||
}
|
}
|
||||||
|
// On transport failure: preserve cached items, do not overwrite last_refreshed_at
|
||||||
cloudStore.setBrowseState({ freshness: 'stale', err: error.value })
|
cloudStore.setBrowseState({ freshness: 'stale', err: error.value })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -106,15 +166,6 @@ async function load() {
|
|||||||
saveLastFolder(connectionId.value, folderId.value)
|
saveLastFolder(connectionId.value, folderId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateTo(item) {
|
|
||||||
router.push(`/cloud/${connectionId.value}/${item.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBreadcrumbNavigate(id) {
|
|
||||||
if (id == null) router.push(`/cloud/${connectionId.value}/root`)
|
|
||||||
else router.push(`/cloud/${connectionId.value}/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onFilesSelected({ files: selectedFiles }) {
|
async function onFilesSelected({ files: selectedFiles }) {
|
||||||
const promises = selectedFiles.map(file => {
|
const promises = selectedFiles.map(file => {
|
||||||
const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' })
|
const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' })
|
||||||
@@ -143,7 +194,7 @@ onMounted(async () => {
|
|||||||
if (isRoot) {
|
if (isRoot) {
|
||||||
const last = loadLastFolder(connectionId.value)
|
const last = loadLastFolder(connectionId.value)
|
||||||
if (last) {
|
if (last) {
|
||||||
router.replace(`/cloud/${connectionId.value}/${last}`)
|
router.replace({ name: 'cloud-folder', params: { connectionId: connectionId.value, folderId: last } })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,10 @@ describe('renders_kind_folder_and_file_in_shared_browser', () => {
|
|||||||
capturedFolders = this.folders
|
capturedFolders = this.folders
|
||||||
capturedFiles = this.files
|
capturedFiles = this.files
|
||||||
},
|
},
|
||||||
|
updated() {
|
||||||
|
capturedFolders = this.folders
|
||||||
|
capturedFiles = this.files
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const w = mount(CloudFolderView, {
|
const w = mount(CloudFolderView, {
|
||||||
global: { stubs: { StorageBrowser: CapturingStub } },
|
global: { stubs: { StorageBrowser: CapturingStub } },
|
||||||
@@ -213,16 +217,20 @@ describe('folder_click_uses_provider_item_id_not_id', () => {
|
|||||||
capabilities: null,
|
capabilities: null,
|
||||||
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' },
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' },
|
||||||
})
|
})
|
||||||
let navigateHandler = null
|
let didEmit = false
|
||||||
const CapturingStub = {
|
const CapturingStub = {
|
||||||
template: '<div data-test="storage-browser" />',
|
template: '<div data-test="storage-browser" />',
|
||||||
props: ['folders', 'files', 'mode', 'breadcrumb', 'uploadQueue', 'loading',
|
props: ['folders', 'files', 'mode', 'breadcrumb', 'uploadQueue', 'loading',
|
||||||
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
|
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
|
||||||
'folderFreshness', 'lastRefreshedAt', 'byteAvailability'],
|
'folderFreshness', 'lastRefreshedAt', 'byteAvailability'],
|
||||||
emits: ['folder-navigate'],
|
emits: ['folder-navigate'],
|
||||||
mounted() {
|
mounted() {},
|
||||||
// Simulate a folder-navigate event with the first folder item
|
updated() {
|
||||||
this.$emit('folder-navigate', this.folders[0])
|
// Emit folder-navigate once folders are populated (after load completes)
|
||||||
|
if (this.folders.length > 0 && !didEmit) {
|
||||||
|
didEmit = true
|
||||||
|
this.$emit('folder-navigate', this.folders[0])
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const w = mount(CloudFolderView, {
|
const w = mount(CloudFolderView, {
|
||||||
@@ -230,18 +238,29 @@ describe('folder_click_uses_provider_item_id_not_id', () => {
|
|||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
// navigateTo must use provider_item_id for the route param, not DocuVault id
|
// navigateTo must use provider_item_id for the route param, not DocuVault id
|
||||||
// The route must contain provider_item_id value, not the uuid id
|
|
||||||
expect(mockPush).toHaveBeenCalled()
|
expect(mockPush).toHaveBeenCalled()
|
||||||
const pushArg = mockPush.mock.calls[0][0]
|
// Find the push call that carries provider_item_id (not the session-restore push)
|
||||||
// Must use provider_item_id in the route, not the DocuVault UUID
|
const hasPidRef = mockPush.mock.calls.some(call => {
|
||||||
if (typeof pushArg === 'string') {
|
const arg = call[0]
|
||||||
expect(pushArg).toContain(FOLDER_A.provider_item_id)
|
if (typeof arg === 'string') return arg.includes(FOLDER_A.provider_item_id)
|
||||||
expect(pushArg).not.toContain(FOLDER_A.id)
|
if (typeof arg === 'object') {
|
||||||
} else if (typeof pushArg === 'object') {
|
const paramValues = Object.values(arg.params ?? {})
|
||||||
// Named route object — params must carry provider_item_id
|
return paramValues.some(v => String(v) === FOLDER_A.provider_item_id)
|
||||||
const paramValues = Object.values(pushArg.params ?? {})
|
}
|
||||||
expect(paramValues.some(v => String(v).includes(FOLDER_A.provider_item_id) || v === FOLDER_A.provider_item_id)).toBe(true)
|
return false
|
||||||
}
|
})
|
||||||
|
expect(hasPidRef).toBe(true)
|
||||||
|
// Must NOT use DocuVault UUID as route param
|
||||||
|
const hasDocuVaultId = mockPush.mock.calls.some(call => {
|
||||||
|
const arg = call[0]
|
||||||
|
if (typeof arg === 'string') return arg.includes(FOLDER_A.id)
|
||||||
|
if (typeof arg === 'object') {
|
||||||
|
const paramValues = Object.values(arg.params ?? {})
|
||||||
|
return paramValues.some(v => String(v) === FOLDER_A.id)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
expect(hasDocuVaultId).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -259,6 +278,7 @@ describe('stable_docuvault_id_remains_row_key', () => {
|
|||||||
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
|
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
|
||||||
'folderFreshness', 'lastRefreshedAt', 'byteAvailability'],
|
'folderFreshness', 'lastRefreshedAt', 'byteAvailability'],
|
||||||
mounted() { capturedFolders = this.folders },
|
mounted() { capturedFolders = this.folders },
|
||||||
|
updated() { capturedFolders = this.folders },
|
||||||
}
|
}
|
||||||
const w = mount(CloudFolderView, {
|
const w = mount(CloudFolderView, {
|
||||||
global: { stubs: { StorageBrowser: CapturingStub } },
|
global: { stubs: { StorageBrowser: CapturingStub } },
|
||||||
@@ -279,14 +299,22 @@ describe('opaque_reference_round_trip', () => {
|
|||||||
capabilities: null,
|
capabilities: null,
|
||||||
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' },
|
freshness: { refresh_state: 'fresh', last_refreshed_at: '2026-06-22T00:00:00Z' },
|
||||||
})
|
})
|
||||||
|
let capturedFolders = []
|
||||||
|
let didEmit = false
|
||||||
const CapturingStub = {
|
const CapturingStub = {
|
||||||
template: '<div data-test="storage-browser" />',
|
template: '<div data-test="storage-browser" />',
|
||||||
props: ['folders', 'files', 'mode', 'breadcrumb', 'uploadQueue', 'loading',
|
props: ['folders', 'files', 'mode', 'breadcrumb', 'uploadQueue', 'loading',
|
||||||
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
|
'emptyMessage', 'emptyHint', 'capabilities', 'connectionRoot',
|
||||||
'folderFreshness', 'lastRefreshedAt', 'byteAvailability'],
|
'folderFreshness', 'lastRefreshedAt', 'byteAvailability'],
|
||||||
emits: ['folder-navigate'],
|
emits: ['folder-navigate'],
|
||||||
mounted() {
|
mounted() { capturedFolders = this.folders },
|
||||||
this.$emit('folder-navigate', this.folders[0])
|
updated() {
|
||||||
|
capturedFolders = this.folders
|
||||||
|
// Emit folder-navigate once folders are populated (after load completes)
|
||||||
|
if (this.folders.length > 0 && !didEmit) {
|
||||||
|
didEmit = true
|
||||||
|
this.$emit('folder-navigate', this.folders[0])
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const w = mount(CloudFolderView, {
|
const w = mount(CloudFolderView, {
|
||||||
@@ -295,15 +323,19 @@ describe('opaque_reference_round_trip', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
// Navigation must use provider_item_id verbatim — no splitting or decoding
|
// Navigation must use provider_item_id verbatim — no splitting or decoding
|
||||||
expect(mockPush).toHaveBeenCalled()
|
expect(mockPush).toHaveBeenCalled()
|
||||||
const pushArg = mockPush.mock.calls[0][0]
|
// Find the push call that was triggered by folder-navigate (not the initial replace)
|
||||||
// The provider_item_id must not be split or have reserved chars stripped
|
const pushCalls = mockPush.mock.calls
|
||||||
if (typeof pushArg === 'object') {
|
// At least one call should carry the opaque provider_item_id
|
||||||
const paramValues = Object.values(pushArg.params ?? {})
|
const hasOpaqueRef = pushCalls.some(call => {
|
||||||
// At least one param must equal or contain the full opaque ref
|
const arg = call[0]
|
||||||
expect(
|
if (typeof arg === 'string') return arg.includes(FOLDER_OPAQUE.provider_item_id)
|
||||||
paramValues.some(v => String(v) === FOLDER_OPAQUE.provider_item_id)
|
if (typeof arg === 'object') {
|
||||||
).toBe(true)
|
const paramValues = Object.values(arg.params ?? {})
|
||||||
}
|
return paramValues.some(v => String(v) === FOLDER_OPAQUE.provider_item_id)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
expect(hasOpaqueRef).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user