跳到主要内容
知识地图检验一个只读 ProviderArcBlock 体系

你在这里。看看这个问题与其他知识怎样相连。

选择节点前往页面 · 展开后可留在地图中阅读

知识地图沿着连接,读懂一个问题
← AFS

一个问题

检验一个只读 Provider

把报告挂到 /work,再用共享测试和真实读取检查实现。

在已有开发工作区里运行

目标是把一份固定报告暴露为 /work/report。这不是持久化存储,也不修改报告。你需要 Bun 和可用的 @aigne/afs、@aigne/afs-testing 包;本例在本地 Arc 工作区的 2.0.0-beta.54 构建产物上验证,未验证从公共包仓库全新安装。

本实验面向已有、已构建的 Arc 开发工作区的读者。公共 npm 上没有核实到本例要求的 @aigne/afs@2.0.0-beta.54,因此不要把下面步骤当成面向新用户的一键安装。

建立一个空的 afs-provider-example 目录,把本页两段 JavaScript 分别保存为 setup.mjs 和 report.test.js,然后进入该目录。把命令中的路径换成你自己的 Arc 工作区(其中两个包已构建,且版本为 2.0.0-beta.54):

sh
bun setup.mjs /path/to/your/built/arc-checkout
bun test report.test.js

把下面的准备脚本保存为 setup.mjs:

javascript
import { readFile, mkdir, symlink, lstat } from 'node:fs/promises';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const checkout = process.argv[2];
if (!checkout) throw new Error('Usage: bun setup.mjs /path/to/your/built/arc-checkout');
const root = dirname(fileURLToPath(import.meta.url));
const modules = join(root, 'node_modules', '@aigne');
await mkdir(modules, { recursive: true });
for (const [name, folder] of [['afs', 'core'], ['afs-testing', 'testing']]) {
  const target = resolve(checkout, 'packages', folder);
  const pkg = JSON.parse(await readFile(join(target, 'package.json'), 'utf8'));
  if (pkg.version !== '2.0.0-beta.54') {
    throw new Error(`Expected ${name} 2.0.0-beta.54; found ${pkg.version}`);
  }
  await readFile(join(target, 'dist', 'index.mjs'));
  const link = join(modules, name);
  const exists = await lstat(link).then(() => true, () => false);
  if (exists) throw new Error(`${link} already exists; use a fresh example folder.`);
  await symlink(target, link, 'dir');
}
console.log('Ready. Run: bun test report.test.js');

准备脚本检查包版本与构建产物,并只在实验目录里连接依赖,不修改 Arc 源码。没有该开发环境时,可以先阅读代码与路径变化,继续沿知识路线理解契约。

把下面保存为 report.test.js,放在能解析这两个包的项目中,运行 bun test report.test.js。这里显式调用路由装饰器注册函数,作用与对应的 @Read、@List 等声明相同;使用普通 JavaScript,不需要 TypeScript 装饰器编译配置。

javascript
import { expect, test } from 'bun:test';
import { AFS, AFSBaseProvider, Read, List, Explain, Meta } from '@aigne/afs';
import { runProviderTests } from '@aigne/afs-testing';

class ReportProvider extends AFSBaseProvider {
  name = 'report-example';

  async root() {
    return { id: 'root', path: '/', meta: { childrenCount: 1 } };
  }
  async report() {
    return {
      id: 'report', path: '/report', meta: { kind: 'afs:file' },
      content: 'Prepare the keynote',
    };
  }
  async children() { return { data: [await this.report()] }; }
  async noChildren() { return { data: [] }; }
  async metadata(ctx) {
    return {
      id: 'meta', path: ctx.path, meta: { kind: 'afs:metadata' },
      content: { title: 'Keynote report' },
    };
  }
  async explainRoot() {
    return {
      content: 'Read /report for the keynote title. This example is read-only.',
      format: 'text',
    };
  }
  async capabilities() {
    return {
      id: '.capabilities', path: '/.meta/.capabilities',
      content: {
        schemaVersion: 1, provider: this.name,
        operations: {
          read: true, list: true, explain: true, stat: false,
          write: false, delete: false, search: false, exec: false,
        },
      },
    };
  }
}

const routes = [
  [Read, '/', 'root'],
  [Read, '/report', 'report'],
  [List, '/', 'children'],
  [List, '/report', 'noChildren'],
  [Meta, '/', 'metadata'],
  [Meta, '/report', 'metadata'],
  [Read, '/.meta/.capabilities', 'capabilities'],
  [Explain, '/', 'explainRoot'],
];
for (const [decorator, path, method] of routes) {
  decorator(path)(
    ReportProvider.prototype, method,
    Object.getOwnPropertyDescriptor(ReportProvider.prototype, method),
  );
}

runProviderTests({
  name: 'ReportProvider learning example',
  createProvider: () => new ReportProvider(),
  playground: async () => ({
    name: 'Report example', mountPath: '/work',
    provider: new ReportProvider(), cleanup: async () => {},
  }),
  structure: {
    root: { name: '', children: [
      { name: 'report', content: 'Prepare the keynote' },
    ] },
  },
});

test('mount prefix is applied exactly once', async () => {
  const afs = new AFS();
  await afs.mount(new ReportProvider(), '/work');
  const result = await afs.read('/work/report');
  expect(result.data.path).toBe('/work/report');
  expect(result.data.content).toBe('Prepare the keynote');
});

test('a second mount changes the caller path, not the provider path', async () => {
  const afs = new AFS();
  await afs.mount(new ReportProvider(), '/reviews');
  const result = await afs.read('/reviews/report');
  expect(result.data.path).toBe('/reviews/report');
  expect(result.data.content).toBe('Prepare the keynote');
  await expect(afs.read('/reviews/missing')).rejects.toMatchObject({
    code: 'AFS_NOT_FOUND',
  });
});

从四处读懂它

  • ReportProvider 返回 provider 内部路径,例如 /report。它不知道外部挂载位置是 /work。
  • 元数据入口提供节点说明,能力清单明确哪些操作可用。空子列表说明报告没有下级资源。
  • runProviderTests 使用真实 provider 实例和已知报告内容做共同契约检查。playground 函数为开发调试准备同一个 provider 的可挂载实例。
  • 最后一条测试真的挂载并读取 /work/report,检查返回的 data.path 与正文。只导入类或看到测试数量,都不能替代这一步。

这次运行记录为 222 项通过、6 项跳过、0 项失败;跳过的是订阅通知用例。共享套件还包含按能力返回的检查,因此这个数量不代表实现了 222 种能力。证据限于只读例子的当前版本,不能推断持久化、条件写入或跨进程行为。

改一个地方再观察

把 report() 的正文改掉而保留测试预期,最后的读取测试应失败。再把挂载位置改为 /reviews,同时把读取入口和预期路径改为 /reviews/report;provider 内部的 /report 不需要改。最后尝试读取不存在的 /reviews/missing,应得到路径不存在的错误,而不是空报告。

注册表中的 [Read, "/report", "report"] 表示“读取 provider 内部 /report 时调用 report()”。处理器返回条目本身;afs.read() 返回操作结果,所以调用者通过 result.data.path 和 result.data.content 读取条目。最后一个测试还演示了如何捕获不存在资源的异常,检查错误码 AFS_NOT_FOUND。

检查一下理解

测试通过,是否意味着已经支持写入?

不。这个例子只实现读取、列举、元数据与说明。能力清单明确没有写入;还要看实际断言覆盖什么。

沿着学习路径继续