chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockNotify } = vi.hoisted(() => ({ mockNotify: vi.fn() }))
|
||||
|
||||
vi.mock('@/blocks/custom/client-overlay', () => ({
|
||||
notifyBlockOverlayChanged: mockNotify,
|
||||
}))
|
||||
|
||||
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
|
||||
// client-boundary-allow: vitest ignores the 'use client' directive; this node-env test exercises the module directly
|
||||
import { hydrateBlockVisibility, resetBlockVisibilityForSwitch } from '@/blocks/visibility/client'
|
||||
import { overlayVisibility, registerBlockCacheInvalidator } from '@/blocks/visibility/context'
|
||||
|
||||
function state(revealed: string[], disabled: string[] = []): BlockVisibilityState {
|
||||
return {
|
||||
revealed: new Set(revealed),
|
||||
disabled: new Set(disabled),
|
||||
previewTagged: new Set(revealed),
|
||||
}
|
||||
}
|
||||
|
||||
describe('hydrateBlockVisibility', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
// Must run FIRST: module state starts null and persists across tests.
|
||||
it('treats an empty state while none is set as a no-op (null ≡ empty)', () => {
|
||||
const invalidator = vi.fn()
|
||||
const unregister = registerBlockCacheInvalidator(invalidator)
|
||||
|
||||
hydrateBlockVisibility(state([]))
|
||||
expect(overlayVisibility()).toBeNull()
|
||||
expect(invalidator).not.toHaveBeenCalled()
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('applies state, fires invalidators, and bumps the overlay version', () => {
|
||||
const invalidator = vi.fn()
|
||||
const unregister = registerBlockCacheInvalidator(invalidator)
|
||||
|
||||
hydrateBlockVisibility(state(['gmail_v2']))
|
||||
expect(overlayVisibility()?.revealed.has('gmail_v2')).toBe(true)
|
||||
expect(invalidator).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotify).toHaveBeenCalledTimes(1)
|
||||
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('drops reveals but carries kill switches over on workspace switch', () => {
|
||||
const invalidator = vi.fn()
|
||||
const unregister = registerBlockCacheInvalidator(invalidator)
|
||||
|
||||
// Reveal + kill-switch in workspace A, then switch (loader resets while the
|
||||
// new projection loads). (notion_v3: distinct from prior tests' state —
|
||||
// module state persists.)
|
||||
hydrateBlockVisibility(state(['notion_v3'], ['slack']))
|
||||
resetBlockVisibilityForSwitch()
|
||||
expect(overlayVisibility()?.revealed.size).toBe(0)
|
||||
expect(overlayVisibility()?.previewTagged.size).toBe(0)
|
||||
expect(overlayVisibility()?.disabled).toEqual(new Set(['slack']))
|
||||
expect(invalidator).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Repeated resets are no-ops (deep-equal).
|
||||
resetBlockVisibilityForSwitch()
|
||||
expect(invalidator).toHaveBeenCalledTimes(2)
|
||||
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('no-ops on a deep-equal state (fresh objects, same content)', () => {
|
||||
const invalidator = vi.fn()
|
||||
const unregister = registerBlockCacheInvalidator(invalidator)
|
||||
|
||||
hydrateBlockVisibility(state(['gmail_v2'], ['slack']))
|
||||
hydrateBlockVisibility(state(['gmail_v2'], ['slack']))
|
||||
expect(invalidator).toHaveBeenCalledTimes(1)
|
||||
expect(mockNotify).toHaveBeenCalledTimes(1)
|
||||
|
||||
hydrateBlockVisibility(state(['gmail_v2', 'notion_v3'], ['slack']))
|
||||
expect(invalidator).toHaveBeenCalledTimes(2)
|
||||
expect(mockNotify).toHaveBeenCalledTimes(2)
|
||||
|
||||
unregister()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
|
||||
import { notifyBlockOverlayChanged } from '@/blocks/custom/client-overlay'
|
||||
import { invalidateBlockCaches, registerBlockVisibilityResolver } from '@/blocks/visibility/context'
|
||||
|
||||
/**
|
||||
* Client-side visibility state, hydrated from `useBlockVisibility` by
|
||||
* `BlockVisibilityLoader`. Registered at module load with `null` state so the
|
||||
* very first render — including the SSR pass — is fail-closed for `preview`
|
||||
* blocks; the post-mount fetch only ever reveals (benign pop-in) or applies a
|
||||
* kill switch to an already-public block.
|
||||
*/
|
||||
let state: BlockVisibilityState | null = null
|
||||
|
||||
registerBlockVisibilityResolver({ current: () => state })
|
||||
|
||||
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false
|
||||
for (const value of a) if (!b.has(value)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isEmptyState(vis: BlockVisibilityState): boolean {
|
||||
return vis.revealed.size === 0 && vis.disabled.size === 0 && vis.previewTagged.size === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the in-scope visibility state, reset registered module caches, and
|
||||
* bump the shared block-overlay version so every subscribed consumer re-reads
|
||||
* `getAllBlocks()`.
|
||||
*
|
||||
* No-ops when the change cannot alter the projection: an incoming state
|
||||
* deep-equal to the current one (React Query refetches deliver
|
||||
* fresh-but-identical objects on every poll — without this guard each poll
|
||||
* would thundering-rebuild the toolbar, search, and matcher caches), or an
|
||||
* empty state while none is set (`null` and empty are equivalent for
|
||||
* `isHiddenUnder`, so the fail-closed reset on first mount is free).
|
||||
*/
|
||||
export function hydrateBlockVisibility(next: BlockVisibilityState): void {
|
||||
if (state === null && isEmptyState(next)) return
|
||||
if (
|
||||
state &&
|
||||
setsEqual(state.revealed, next.revealed) &&
|
||||
setsEqual(state.disabled, next.disabled) &&
|
||||
setsEqual(state.previewTagged, next.previewTagged)
|
||||
) {
|
||||
return
|
||||
}
|
||||
state = next
|
||||
invalidateBlockCaches()
|
||||
notifyBlockOverlayChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed reset while a workspace switch's visibility fetch is in flight:
|
||||
* preview reveals are dropped immediately (they may not apply to the new
|
||||
* workspace), but kill-switch entries are CARRIED OVER until the new
|
||||
* projection arrives — dropping `disabled` would flash kill-switched blocks
|
||||
* back into discovery for the flight window, while briefly over-hiding in the
|
||||
* new workspace is benign in both directions.
|
||||
*/
|
||||
export function resetBlockVisibilityForSwitch(): void {
|
||||
if (state === null) return
|
||||
hydrateBlockVisibility({
|
||||
revealed: new Set(),
|
||||
disabled: state.disabled,
|
||||
previewTagged: new Set(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
/**
|
||||
* Resolver for the per-viewer block-visibility projection, mirroring the
|
||||
* custom-block overlay seam (`@/blocks/custom/overlay`) but deliberately
|
||||
* independent of it: visibility is a discovery concern with its own lifecycle,
|
||||
* and a separate AsyncLocalStorage composes with `withCustomBlockOverlay`
|
||||
* without `store.run` clobbering.
|
||||
*
|
||||
* Two environment-specific resolvers register here:
|
||||
* - client: module state hydrated from `useBlockVisibility` (see `client.ts`)
|
||||
* - server: an AsyncLocalStorage scoped per request (see `server-context.ts`)
|
||||
*
|
||||
* This module is isomorphic (no `'use client'`, no `node:` imports) so
|
||||
* `@/blocks/registry` stays importable on both sides. When NO resolver state is
|
||||
* active, `preview` blocks are still hidden ({@link isHiddenUnder} treats a null
|
||||
* state as "nothing revealed") — fail-closed is the default everywhere,
|
||||
* including SSR and server paths outside `withBlockVisibility`.
|
||||
*/
|
||||
export interface BlockVisibilityResolver {
|
||||
current(): BlockVisibilityState | null
|
||||
}
|
||||
|
||||
let resolver: BlockVisibilityResolver | null = null
|
||||
|
||||
/** Register (or clear with `null`) the active visibility resolver for this environment. */
|
||||
export function registerBlockVisibilityResolver(next: BlockVisibilityResolver | null): void {
|
||||
resolver = next
|
||||
}
|
||||
|
||||
/** The visibility state in scope, or `null` when none (= nothing revealed, nothing disabled). */
|
||||
export function overlayVisibility(): BlockVisibilityState | null {
|
||||
return resolver?.current() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* THE single hidden-predicate for block gating — every surface that hides
|
||||
* blocks (registry projection, VFS stamp filter, exposed-integration-tools
|
||||
* filter, `get_blocks_metadata`) calls this; never restate the rule inline.
|
||||
*
|
||||
* A block is hidden when it is an unrevealed `preview` block (fail-closed even
|
||||
* with a null state) or when the kill switch (`disabled`) names it. Static
|
||||
* `hideFromToolbar` is deliberately NOT part of this predicate — callers that
|
||||
* need it check it separately.
|
||||
*/
|
||||
export function isHiddenUnder(
|
||||
vis: BlockVisibilityState | null,
|
||||
block: Pick<BlockConfig, 'type' | 'preview'>
|
||||
): boolean {
|
||||
if (block.preview && !vis?.revealed.has(block.type)) return true
|
||||
if (vis?.disabled.has(block.type)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of non-React module-cache resets (e.g. the tool-operations search
|
||||
* index, the integration matcher) fired when the client visibility state
|
||||
* changes. Lives here — not in the `'use client'` module — so plain `lib/`
|
||||
* modules can register at load time on either side of the server boundary.
|
||||
*/
|
||||
const cacheInvalidators = new Set<() => void>()
|
||||
|
||||
/** Register a cache reset to run on visibility changes. Returns an unregister fn. */
|
||||
export function registerBlockCacheInvalidator(fn: () => void): () => void {
|
||||
cacheInvalidators.add(fn)
|
||||
return () => cacheInvalidators.delete(fn)
|
||||
}
|
||||
|
||||
/** Fire every registered cache reset (called by the client hydrate path). */
|
||||
export function invalidateBlockCaches(): void {
|
||||
for (const fn of cacheInvalidators) fn()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.unmock('@/blocks/registry')
|
||||
|
||||
const { synthRegistry } = vi.hoisted(() => ({
|
||||
synthRegistry: {
|
||||
slack: {
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
description: '',
|
||||
category: 'tools',
|
||||
bgColor: '#000',
|
||||
icon: () => null,
|
||||
subBlocks: [],
|
||||
tools: { access: [] },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/registry-maps', () => ({
|
||||
BLOCK_REGISTRY: synthRegistry,
|
||||
BLOCK_META_REGISTRY: {},
|
||||
}))
|
||||
|
||||
import { getAllBlocks } from '@/blocks/registry'
|
||||
import { registerBlockVisibilityResolver } from '@/blocks/visibility/context'
|
||||
|
||||
afterEach(() => registerBlockVisibilityResolver(null))
|
||||
|
||||
describe('registry fast path (no preview blocks registered)', () => {
|
||||
it('returns raw references with no context', () => {
|
||||
expect(getAllBlocks()[0]).toBe(synthRegistry.slack)
|
||||
})
|
||||
|
||||
it('returns raw references when the active state has no kill-switch entries', () => {
|
||||
const state = {
|
||||
revealed: new Set(['whatever']),
|
||||
disabled: new Set<string>(),
|
||||
previewTagged: new Set(['whatever']),
|
||||
}
|
||||
registerBlockVisibilityResolver({ current: () => state })
|
||||
expect(getAllBlocks()[0]).toBe(synthRegistry.slack)
|
||||
})
|
||||
|
||||
it('still projects when a kill-switch entry applies', () => {
|
||||
const state = {
|
||||
revealed: new Set<string>(),
|
||||
disabled: new Set(['slack']),
|
||||
previewTagged: new Set<string>(),
|
||||
}
|
||||
registerBlockVisibilityResolver({ current: () => state })
|
||||
expect(getAllBlocks()[0]?.hideFromToolbar).toBe(true)
|
||||
expect(synthRegistry.slack).not.toHaveProperty('hideFromToolbar', true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
|
||||
import { registerBlockVisibilityResolver } from '@/blocks/visibility/context'
|
||||
|
||||
/**
|
||||
* Server-side visibility context: a per-request AsyncLocalStorage, independent
|
||||
* of the custom-block overlay's ALS so `withBlockVisibility` and
|
||||
* `withCustomBlockOverlay` nest in either order without clobbering each other.
|
||||
*
|
||||
* Only copilot/mothership discovery paths establish this scope. Execution entry
|
||||
* points (execute route, trigger.dev tasks, schedules/webhooks) never do — so
|
||||
* placed preview blocks always serialize and run, and `preview` blocks stay
|
||||
* hidden on unscoped discovery reads purely via the static fail-closed default.
|
||||
*/
|
||||
const store = new AsyncLocalStorage<BlockVisibilityState>()
|
||||
|
||||
registerBlockVisibilityResolver({ current: () => store.getStore() ?? null })
|
||||
|
||||
/** Run `fn` with the given visibility state resolvable via the registry accessors. */
|
||||
export function withBlockVisibility<T>(
|
||||
vis: BlockVisibilityState,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return store.run(vis, fn)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.unmock('@/blocks/registry')
|
||||
|
||||
const { synthRegistry } = vi.hoisted(() => {
|
||||
const block = (type: string, extra: Record<string, unknown> = {}) => ({
|
||||
type,
|
||||
name: type.toUpperCase(),
|
||||
description: '',
|
||||
category: 'tools',
|
||||
bgColor: '#000',
|
||||
icon: () => null,
|
||||
subBlocks: [],
|
||||
tools: { access: [] },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
...extra,
|
||||
})
|
||||
return {
|
||||
synthRegistry: {
|
||||
slack: block('slack'),
|
||||
gmail_v2: block('gmail_v2', { preview: true }),
|
||||
old_v1: block('old_v1', { hideFromToolbar: true }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/blocks/registry-maps', () => ({
|
||||
BLOCK_REGISTRY: synthRegistry,
|
||||
BLOCK_META_REGISTRY: {},
|
||||
}))
|
||||
|
||||
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
|
||||
import { registerBlockOverlayResolver } from '@/blocks/custom/overlay'
|
||||
import { getAllBlocks, getBlock, getCanonicalBlocksByCategory } from '@/blocks/registry'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { isHiddenUnder, registerBlockVisibilityResolver } from '@/blocks/visibility/context'
|
||||
|
||||
function vis(partial: Partial<BlockVisibilityState> = {}): BlockVisibilityState {
|
||||
return {
|
||||
revealed: new Set(),
|
||||
disabled: new Set(),
|
||||
previewTagged: new Set(),
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function withVisibility(state: BlockVisibilityState | null) {
|
||||
registerBlockVisibilityResolver(state ? { current: () => state } : null)
|
||||
}
|
||||
|
||||
const byType = (blocks: BlockConfig[], type: string) => blocks.find((b) => b.type === type)
|
||||
|
||||
afterEach(() => {
|
||||
registerBlockVisibilityResolver(null)
|
||||
registerBlockOverlayResolver(null)
|
||||
})
|
||||
|
||||
describe('isHiddenUnder', () => {
|
||||
it('hides unrevealed preview blocks even with a null state (fail-closed)', () => {
|
||||
expect(isHiddenUnder(null, { type: 'gmail_v2', preview: true })).toBe(true)
|
||||
expect(isHiddenUnder(null, { type: 'slack' })).toBe(false)
|
||||
})
|
||||
|
||||
it('reveals preview blocks named in revealed', () => {
|
||||
const state = vis({ revealed: new Set(['gmail_v2']) })
|
||||
expect(isHiddenUnder(state, { type: 'gmail_v2', preview: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('hides kill-switched types only with an active state', () => {
|
||||
const state = vis({ disabled: new Set(['slack']) })
|
||||
expect(isHiddenUnder(state, { type: 'slack' })).toBe(true)
|
||||
expect(isHiddenUnder(null, { type: 'slack' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registry projection', () => {
|
||||
it('hides preview blocks without any context: clone-not-remove', () => {
|
||||
withVisibility(null)
|
||||
const all = getAllBlocks()
|
||||
const gmail = byType(all, 'gmail_v2')
|
||||
expect(gmail).toBeDefined()
|
||||
expect(gmail?.hideFromToolbar).toBe(true)
|
||||
// the underlying registry entry is untouched
|
||||
expect(getBlock('gmail_v2')?.hideFromToolbar).toBeUndefined()
|
||||
// canonical (filtered) set excludes it
|
||||
expect(byType(getCanonicalBlocksByCategory('tools'), 'gmail_v2')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reveals a preview block with a " (Preview)" display suffix when tagged', () => {
|
||||
withVisibility(vis({ revealed: new Set(['gmail_v2']), previewTagged: new Set(['gmail_v2']) }))
|
||||
expect(byType(getAllBlocks(), 'gmail_v2')?.name).toBe('GMAIL_V2 (Preview)')
|
||||
const canonical = byType(getCanonicalBlocksByCategory('tools'), 'gmail_v2')
|
||||
expect(canonical?.name).toBe('GMAIL_V2 (Preview)')
|
||||
expect(canonical?.hideFromToolbar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reveals a config-GA preview block without a suffix', () => {
|
||||
withVisibility(vis({ revealed: new Set(['gmail_v2']) }))
|
||||
expect(byType(getAllBlocks(), 'gmail_v2')?.name).toBe('GMAIL_V2')
|
||||
})
|
||||
|
||||
it('kill-switches a shipped block only when a context is active', () => {
|
||||
withVisibility(vis({ disabled: new Set(['slack']) }))
|
||||
expect(byType(getAllBlocks(), 'slack')?.hideFromToolbar).toBe(true)
|
||||
expect(byType(getCanonicalBlocksByCategory('tools'), 'slack')).toBeUndefined()
|
||||
|
||||
withVisibility(null)
|
||||
expect(byType(getAllBlocks(), 'slack')?.hideFromToolbar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps getBlock pure regardless of visibility', () => {
|
||||
withVisibility(
|
||||
vis({
|
||||
revealed: new Set(['gmail_v2']),
|
||||
previewTagged: new Set(['gmail_v2']),
|
||||
disabled: new Set(['slack']),
|
||||
})
|
||||
)
|
||||
expect(getBlock('gmail_v2')?.name).toBe('GMAIL_V2')
|
||||
expect(getBlock('slack')?.hideFromToolbar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns untouched references for unaffected blocks', () => {
|
||||
withVisibility(vis({ revealed: new Set(['gmail_v2']) }))
|
||||
expect(byType(getAllBlocks(), 'slack')).toBe(synthRegistry.slack)
|
||||
expect(byType(getAllBlocks(), 'old_v1')).toBe(synthRegistry.old_v1)
|
||||
})
|
||||
|
||||
it('never re-clones or suffixes already-hidden custom blocks', () => {
|
||||
const disabledCustom = {
|
||||
...synthRegistry.slack,
|
||||
type: 'custom_block_abc',
|
||||
name: 'My Custom',
|
||||
hideFromToolbar: true,
|
||||
} as BlockConfig
|
||||
registerBlockOverlayResolver({
|
||||
get: (t) => (t === disabledCustom.type ? disabledCustom : undefined),
|
||||
all: () => [disabledCustom],
|
||||
})
|
||||
withVisibility(vis({ revealed: new Set(['gmail_v2']) }))
|
||||
const projected = byType(getAllBlocks(), 'custom_block_abc')
|
||||
expect(projected).toBe(disabledCustom)
|
||||
expect(projected?.name).toBe('My Custom')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user