feat(sync): unify Google auth under :Pending auth (#72)
* feat(sync): unify Google auth under :Pending auth Problem: users had to run `:Pending gtasks auth` and `:Pending gcal auth` separately, producing two token files and two browser consents for the same Google account. Solution: introduce `oauth.google_client` with combined tasks + calendar scopes and a single `google_tokens.json`. Remove per-backend `auth`/`setup` from `gcal` and `gtasks`; add top-level `:Pending auth` that prompts with `vim.ui.select` and delegates to the shared client's `setup()` or `auth()` based on credential availability. * docs: update vimdoc for unified Google auth Problem: `doc/pending.txt` still documented per-backend `:Pending gtasks auth` / `:Pending gcal auth` commands and separate token files, which no longer exist after the auth unification. Solution: add `:Pending auth` entry to COMMANDS and a new `*pending-google-auth*` section covering the shared PKCE flow, combined scopes, and `google_tokens.json`. Remove `auth` from gcal/gtasks action tables and update all cross-references to use `:Pending auth`. * ci: format
This commit is contained in:
parent
87d8bf0896
commit
ad59e894c7
7 changed files with 102 additions and 77 deletions
|
|
@ -65,8 +65,9 @@ CONTENTS *pending-contents*
|
||||||
17. Sync Backends ................................... |pending-sync-backend|
|
17. Sync Backends ................................... |pending-sync-backend|
|
||||||
18. Google Calendar .......................................... |pending-gcal|
|
18. Google Calendar .......................................... |pending-gcal|
|
||||||
19. Google Tasks ............................................ |pending-gtasks|
|
19. Google Tasks ............................................ |pending-gtasks|
|
||||||
20. Data Format .............................................. |pending-data|
|
20. Google Authentication ......................... |pending-google-auth|
|
||||||
21. Health Check ........................................... |pending-health|
|
21. Data Format .............................................. |pending-data|
|
||||||
|
22. Health Check ........................................... |pending-health|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
REQUIREMENTS *pending-requirements*
|
REQUIREMENTS *pending-requirements*
|
||||||
|
|
@ -148,6 +149,15 @@ COMMANDS *pending-commands*
|
||||||
Populate the quickfix list with all tasks that are overdue or due today.
|
Populate the quickfix list with all tasks that are overdue or due today.
|
||||||
Open the list with |:copen| to navigate to each task's category.
|
Open the list with |:copen| to navigate to each task's category.
|
||||||
|
|
||||||
|
*:Pending-auth*
|
||||||
|
:Pending auth
|
||||||
|
Authorize pending.nvim to access Google services (Tasks and Calendar).
|
||||||
|
Prompts with |vim.ui.select| to choose gtasks, gcal, or both — all
|
||||||
|
options run the same combined OAuth flow and produce a single shared
|
||||||
|
token file. If no credentials are configured, the setup wizard runs
|
||||||
|
first to collect a client ID and secret.
|
||||||
|
See |pending-google-auth| for full details.
|
||||||
|
|
||||||
*:Pending-gtasks*
|
*:Pending-gtasks*
|
||||||
:Pending gtasks {action}
|
:Pending gtasks {action}
|
||||||
Run a Google Tasks action. An explicit action is required.
|
Run a Google Tasks action. An explicit action is required.
|
||||||
|
|
@ -156,13 +166,11 @@ COMMANDS *pending-commands*
|
||||||
`sync` Push local changes then pull remote changes.
|
`sync` Push local changes then pull remote changes.
|
||||||
`push` Push local changes to Google Tasks only.
|
`push` Push local changes to Google Tasks only.
|
||||||
`pull` Pull remote changes from Google Tasks only.
|
`pull` Pull remote changes from Google Tasks only.
|
||||||
`auth` Run the OAuth authorization flow.
|
|
||||||
|
|
||||||
Examples: >vim
|
Examples: >vim
|
||||||
:Pending gtasks sync " push then pull
|
:Pending gtasks sync " push then pull
|
||||||
:Pending gtasks push " push local → Google Tasks
|
:Pending gtasks push " push local → Google Tasks
|
||||||
:Pending gtasks pull " pull Google Tasks → local
|
:Pending gtasks pull " pull Google Tasks → local
|
||||||
:Pending gtasks auth " authorize
|
|
||||||
<
|
<
|
||||||
|
|
||||||
Tab completion after `:Pending gtasks ` lists available actions.
|
Tab completion after `:Pending gtasks ` lists available actions.
|
||||||
|
|
@ -174,11 +182,9 @@ COMMANDS *pending-commands*
|
||||||
|
|
||||||
Actions: ~
|
Actions: ~
|
||||||
`push` Push tasks with due dates to Google Calendar.
|
`push` Push tasks with due dates to Google Calendar.
|
||||||
`auth` Run the OAuth authorization flow.
|
|
||||||
|
|
||||||
Examples: >vim
|
Examples: >vim
|
||||||
:Pending gcal push " push to Google Calendar
|
:Pending gcal push " push to Google Calendar
|
||||||
:Pending gcal auth " authorize
|
|
||||||
<
|
<
|
||||||
|
|
||||||
Tab completion after `:Pending gcal ` lists available actions.
|
Tab completion after `:Pending gcal ` lists available actions.
|
||||||
|
|
@ -920,7 +926,6 @@ Each module returns a table conforming to the backend interface: >lua
|
||||||
|
|
||||||
---@class pending.SyncBackend
|
---@class pending.SyncBackend
|
||||||
---@field name string
|
---@field name string
|
||||||
---@field auth fun(): nil
|
|
||||||
---@field push? fun(): nil
|
---@field push? fun(): nil
|
||||||
---@field pull? fun(): nil
|
---@field pull? fun(): nil
|
||||||
---@field sync? fun(): nil
|
---@field sync? fun(): nil
|
||||||
|
|
@ -929,15 +934,17 @@ Each module returns a table conforming to the backend interface: >lua
|
||||||
|
|
||||||
Required fields: ~
|
Required fields: ~
|
||||||
{name} Backend identifier (matches the filename).
|
{name} Backend identifier (matches the filename).
|
||||||
{sync} Main sync action. Called by `:Pending <name>`.
|
|
||||||
{auth} Authorization flow. Called by `:Pending <name> auth`.
|
|
||||||
|
|
||||||
Optional fields: ~
|
Optional fields: ~
|
||||||
{push} Push-only action. Called by `:Pending <name> push`.
|
{push} Push-only action. Called by `:Pending <name> push`.
|
||||||
{pull} Pull-only action. Called by `:Pending <name> pull`.
|
{pull} Pull-only action. Called by `:Pending <name> pull`.
|
||||||
|
{sync} Main sync action. Called by `:Pending <name> sync`.
|
||||||
{health} Called by `:checkhealth pending` to report backend-specific
|
{health} Called by `:checkhealth pending` to report backend-specific
|
||||||
diagnostics (e.g. checking for external tools).
|
diagnostics (e.g. checking for external tools).
|
||||||
|
|
||||||
|
Note: authorization is not a per-backend action. Use `:Pending auth` to
|
||||||
|
authenticate all Google backends at once. See |pending-google-auth|.
|
||||||
|
|
||||||
Backend-specific configuration goes under `sync.<name>` in |pending-config|.
|
Backend-specific configuration goes under `sync.<name>` in |pending-config|.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
|
|
@ -957,7 +964,7 @@ Configuration: >lua
|
||||||
<
|
<
|
||||||
|
|
||||||
No configuration is required to get started — bundled OAuth credentials are
|
No configuration is required to get started — bundled OAuth credentials are
|
||||||
used by default. Run `:Pending gcal auth` and the browser opens immediately.
|
used by default. Run `:Pending auth` and the browser opens immediately.
|
||||||
|
|
||||||
*pending.GcalConfig*
|
*pending.GcalConfig*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
|
|
@ -983,15 +990,8 @@ Credentials are resolved in order:
|
||||||
3. Bundled credentials shipped with the plugin (always available).
|
3. Bundled credentials shipped with the plugin (always available).
|
||||||
|
|
||||||
OAuth flow: ~
|
OAuth flow: ~
|
||||||
On the first `:Pending gcal` call the plugin detects that no refresh token
|
See |pending-google-auth|. Tokens are shared with the gtasks backend and
|
||||||
exists and opens the Google authorization URL in the browser using
|
stored at `stdpath('data')/pending/google_tokens.json`.
|
||||||
|vim.ui.open()|. A temporary local HTTP server listens on port 18392 for the
|
|
||||||
OAuth redirect. The PKCE (Proof Key for Code Exchange) flow is used. After
|
|
||||||
the user grants consent, the
|
|
||||||
authorization code is exchanged for tokens and the refresh token is stored at
|
|
||||||
`stdpath('data')/pending/gcal_tokens.json` with mode `600`. Subsequent syncs
|
|
||||||
use the stored refresh token and refresh the access token automatically when
|
|
||||||
it is about to expire.
|
|
||||||
|
|
||||||
`:Pending gcal push` behavior: ~
|
`:Pending gcal push` behavior: ~
|
||||||
For each task in the store:
|
For each task in the store:
|
||||||
|
|
@ -1020,7 +1020,7 @@ Configuration: >lua
|
||||||
<
|
<
|
||||||
|
|
||||||
No configuration is required to get started — bundled OAuth credentials are
|
No configuration is required to get started — bundled OAuth credentials are
|
||||||
used by default. Run `:Pending gtasks auth` and the browser opens immediately.
|
used by default. Run `:Pending auth` and the browser opens immediately.
|
||||||
|
|
||||||
*pending.GtasksConfig*
|
*pending.GtasksConfig*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
|
|
@ -1043,9 +1043,8 @@ Credential resolution: ~
|
||||||
Same three-tier resolution as the gcal backend (see |pending-gcal|).
|
Same three-tier resolution as the gcal backend (see |pending-gcal|).
|
||||||
|
|
||||||
OAuth flow: ~
|
OAuth flow: ~
|
||||||
Same PKCE flow as the gcal backend; listens on port 18393. Tokens are stored
|
See |pending-google-auth|. Tokens are shared with the gcal backend and
|
||||||
at `stdpath('data')/pending/gtasks_tokens.json`. Run `:Pending gtasks auth`
|
stored at `stdpath('data')/pending/google_tokens.json`.
|
||||||
to authorize; subsequent syncs refresh the token automatically.
|
|
||||||
|
|
||||||
`:Pending gtasks` actions: ~
|
`:Pending gtasks` actions: ~
|
||||||
|
|
||||||
|
|
@ -1077,6 +1076,42 @@ the remainder is ignored.
|
||||||
Recurrence (`rec:`) is stored in notes for round-tripping but is not
|
Recurrence (`rec:`) is stored in notes for round-tripping but is not
|
||||||
expanded by Google Tasks (GTasks has no recurrence API).
|
expanded by Google Tasks (GTasks has no recurrence API).
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
GOOGLE AUTHENTICATION *pending-google-auth*
|
||||||
|
|
||||||
|
Both the gcal and gtasks backends share a single OAuth client with combined
|
||||||
|
scopes (`tasks` + `calendar`). One authorization flow covers both services
|
||||||
|
and produces one token file.
|
||||||
|
|
||||||
|
:Pending auth ~
|
||||||
|
Prompts with |vim.ui.select| offering three options: `gtasks`, `gcal`, and
|
||||||
|
`both`. All three options run the identical combined OAuth flow — the choice
|
||||||
|
is informational only. If no real credentials are configured (i.e. bundled
|
||||||
|
placeholders are in use), the setup wizard runs first to collect a client ID
|
||||||
|
and client secret before opening the browser.
|
||||||
|
|
||||||
|
OAuth flow: ~
|
||||||
|
A PKCE (Proof Key for Code Exchange) flow is used:
|
||||||
|
1. A random 64-character `code_verifier` is generated.
|
||||||
|
2. Its SHA-256 hash is base64url-encoded as the `code_challenge`.
|
||||||
|
3. The Google authorization URL is opened in the browser via |vim.ui.open()|.
|
||||||
|
4. A temporary TCP server on port 18392 waits up to 120 seconds for the
|
||||||
|
OAuth redirect.
|
||||||
|
5. The authorization code is exchanged for tokens via `curl`.
|
||||||
|
6. The refresh token is written to
|
||||||
|
`stdpath('data')/pending/google_tokens.json` with mode `600`.
|
||||||
|
7. Subsequent syncs refresh the access token automatically when it is about
|
||||||
|
to expire (within 60 seconds of the `expires_in` window).
|
||||||
|
|
||||||
|
Credential resolution: ~
|
||||||
|
Credentials are resolved in order for the `google` config key:
|
||||||
|
1. `client_id` + `client_secret` under `sync.google` (highest priority).
|
||||||
|
2. JSON file at `sync.google.credentials_path` or the default path
|
||||||
|
`stdpath('data')/pending/google_credentials.json`.
|
||||||
|
3. Bundled placeholder credentials (always available; trigger setup wizard).
|
||||||
|
|
||||||
|
The `installed` wrapper format from the Google Cloud Console is accepted.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
DATA FORMAT *pending-data*
|
DATA FORMAT *pending-data*
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -828,6 +828,24 @@ function M.edit(id_str, rest)
|
||||||
log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
|
log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return nil
|
||||||
|
function M.auth()
|
||||||
|
local oauth = require('pending.sync.oauth')
|
||||||
|
vim.ui.select({ 'gtasks', 'gcal', 'both' }, {
|
||||||
|
prompt = 'Authenticate:',
|
||||||
|
}, function(choice)
|
||||||
|
if not choice then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local creds = oauth.google_client:resolve_credentials()
|
||||||
|
if creds.client_id == oauth.BUNDLED_CLIENT_ID then
|
||||||
|
oauth.google_client:setup()
|
||||||
|
else
|
||||||
|
oauth.google_client:auth()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
---@param args string
|
---@param args string
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.command(args)
|
function M.command(args)
|
||||||
|
|
@ -841,6 +859,8 @@ function M.command(args)
|
||||||
elseif cmd == 'edit' then
|
elseif cmd == 'edit' then
|
||||||
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
|
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
|
||||||
M.edit(id_str, edit_rest)
|
M.edit(id_str, edit_rest)
|
||||||
|
elseif cmd == 'auth' then
|
||||||
|
M.auth()
|
||||||
elseif SYNC_BACKEND_SET[cmd] then
|
elseif SYNC_BACKEND_SET[cmd] then
|
||||||
local action = rest:match('^(%S+)')
|
local action = rest:match('^(%S+)')
|
||||||
run_sync(cmd, action)
|
run_sync(cmd, action)
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,6 @@ local M = {}
|
||||||
M.name = 'gcal'
|
M.name = 'gcal'
|
||||||
|
|
||||||
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
local BASE_URL = 'https://www.googleapis.com/calendar/v3'
|
||||||
local SCOPE = 'https://www.googleapis.com/auth/calendar'
|
|
||||||
|
|
||||||
local client = oauth.new({
|
|
||||||
name = 'gcal',
|
|
||||||
scope = SCOPE,
|
|
||||||
port = 18392,
|
|
||||||
config_key = 'gcal',
|
|
||||||
})
|
|
||||||
|
|
||||||
---@param access_token string
|
---@param access_token string
|
||||||
---@return table<string, string>? name_to_id
|
---@return table<string, string>? name_to_id
|
||||||
|
|
@ -139,15 +131,15 @@ end
|
||||||
---@param callback fun(access_token: string): nil
|
---@param callback fun(access_token: string): nil
|
||||||
local function with_token(callback)
|
local function with_token(callback)
|
||||||
oauth.async(function()
|
oauth.async(function()
|
||||||
local token = client:get_access_token()
|
local token = oauth.google_client:get_access_token()
|
||||||
if not token then
|
if not token then
|
||||||
client:auth(function()
|
oauth.google_client:auth(function()
|
||||||
oauth.async(function()
|
oauth.async(function()
|
||||||
local fresh = client:get_access_token()
|
local fresh = oauth.google_client:get_access_token()
|
||||||
if fresh then
|
if fresh then
|
||||||
callback(fresh)
|
callback(fresh)
|
||||||
else
|
else
|
||||||
log.error(client.name .. ': authorization failed or was cancelled')
|
log.error(oauth.google_client.name .. ': authorization failed or was cancelled')
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
@ -157,14 +149,6 @@ local function with_token(callback)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.setup()
|
|
||||||
client:setup()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.auth()
|
|
||||||
client:auth()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.push()
|
function M.push()
|
||||||
with_token(function(access_token)
|
with_token(function(access_token)
|
||||||
local calendars, cal_err = get_all_calendars(access_token)
|
local calendars, cal_err = get_all_calendars(access_token)
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,6 @@ local M = {}
|
||||||
M.name = 'gtasks'
|
M.name = 'gtasks'
|
||||||
|
|
||||||
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
|
local BASE_URL = 'https://tasks.googleapis.com/tasks/v1'
|
||||||
local SCOPE = 'https://www.googleapis.com/auth/tasks'
|
|
||||||
|
|
||||||
local client = oauth.new({
|
|
||||||
name = 'gtasks',
|
|
||||||
scope = SCOPE,
|
|
||||||
port = 18393,
|
|
||||||
config_key = 'gtasks',
|
|
||||||
})
|
|
||||||
|
|
||||||
---@param access_token string
|
---@param access_token string
|
||||||
---@return table<string, string>? name_to_id
|
---@return table<string, string>? name_to_id
|
||||||
|
|
@ -355,15 +347,15 @@ end
|
||||||
---@param callback fun(access_token: string): nil
|
---@param callback fun(access_token: string): nil
|
||||||
local function with_token(callback)
|
local function with_token(callback)
|
||||||
oauth.async(function()
|
oauth.async(function()
|
||||||
local token = client:get_access_token()
|
local token = oauth.google_client:get_access_token()
|
||||||
if not token then
|
if not token then
|
||||||
client:auth(function()
|
oauth.google_client:auth(function()
|
||||||
oauth.async(function()
|
oauth.async(function()
|
||||||
local fresh = client:get_access_token()
|
local fresh = oauth.google_client:get_access_token()
|
||||||
if fresh then
|
if fresh then
|
||||||
callback(fresh)
|
callback(fresh)
|
||||||
else
|
else
|
||||||
log.error(client.name .. ': authorization failed or was cancelled')
|
log.error(oauth.google_client.name .. ': authorization failed or was cancelled')
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
@ -373,14 +365,6 @@ local function with_token(callback)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.setup()
|
|
||||||
client:setup()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.auth()
|
|
||||||
client:auth()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.push()
|
function M.push()
|
||||||
with_token(function(access_token)
|
with_token(function(access_token)
|
||||||
local tasklists, s, now_ts = sync_setup(access_token)
|
local tasklists, s, now_ts = sync_setup(access_token)
|
||||||
|
|
@ -462,11 +446,11 @@ M._gtask_to_fields = gtask_to_fields
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.health()
|
function M.health()
|
||||||
oauth.health(M.name)
|
oauth.health(M.name)
|
||||||
local tokens = client:load_tokens()
|
local tokens = oauth.google_client:load_tokens()
|
||||||
if tokens and tokens.refresh_token then
|
if tokens and tokens.refresh_token then
|
||||||
vim.health.ok('gtasks tokens found')
|
vim.health.ok('gtasks tokens found')
|
||||||
else
|
else
|
||||||
vim.health.info('no gtasks tokens — run :Pending gtasks auth')
|
vim.health.info('no gtasks tokens — run :Pending auth')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -501,5 +501,13 @@ end
|
||||||
|
|
||||||
M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
|
M._BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
|
||||||
M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET
|
M._BUNDLED_CLIENT_SECRET = BUNDLED_CLIENT_SECRET
|
||||||
|
M.BUNDLED_CLIENT_ID = BUNDLED_CLIENT_ID
|
||||||
|
|
||||||
|
M.google_client = M.new({
|
||||||
|
name = 'google',
|
||||||
|
scope = 'https://www.googleapis.com/auth/tasks' .. ' https://www.googleapis.com/auth/calendar',
|
||||||
|
port = 18392,
|
||||||
|
config_key = 'google',
|
||||||
|
})
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ end, {
|
||||||
nargs = '*',
|
nargs = '*',
|
||||||
complete = function(arg_lead, cmd_line)
|
complete = function(arg_lead, cmd_line)
|
||||||
local pending = require('pending')
|
local pending = require('pending')
|
||||||
local subcmds = { 'add', 'archive', 'due', 'edit', 'filter', 'undo' }
|
local subcmds = { 'add', 'archive', 'auth', 'due', 'edit', 'filter', 'undo' }
|
||||||
for _, b in ipairs(pending.sync_backends()) do
|
for _, b in ipairs(pending.sync_backends()) do
|
||||||
table.insert(subcmds, b)
|
table.insert(subcmds, b)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -73,15 +73,14 @@ describe('sync', function()
|
||||||
assert.is_true(called)
|
assert.is_true(called)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('routes auth action', function()
|
it('routes auth command', function()
|
||||||
local called = false
|
local called = false
|
||||||
local gcal = require('pending.sync.gcal')
|
local orig_auth = pending.auth
|
||||||
local orig_auth = gcal.auth
|
pending.auth = function()
|
||||||
gcal.auth = function()
|
|
||||||
called = true
|
called = true
|
||||||
end
|
end
|
||||||
pending.command('gcal auth')
|
pending.command('auth')
|
||||||
gcal.auth = orig_auth
|
pending.auth = orig_auth
|
||||||
assert.is_true(called)
|
assert.is_true(called)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
@ -102,11 +101,6 @@ describe('sync', function()
|
||||||
assert.are.equal('gcal', gcal.name)
|
assert.are.equal('gcal', gcal.name)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('has auth function', function()
|
|
||||||
local gcal = require('pending.sync.gcal')
|
|
||||||
assert.are.equal('function', type(gcal.auth))
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('has push function', function()
|
it('has push function', function()
|
||||||
local gcal = require('pending.sync.gcal')
|
local gcal = require('pending.sync.gcal')
|
||||||
assert.are.equal('function', type(gcal.push))
|
assert.are.equal('function', type(gcal.push))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue